claude-rpc 0.13.4 → 0.13.6

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.6",
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,11 +366,13 @@ 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) || '';
373
- if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
373
+ // ETIMEDOUT: request() now deadlines nonce replies a half-open pipe that
374
+ // acks writes but never answers is a dead transport, so reconnect.
375
+ if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ETIMEDOUT', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
374
376
  const m = String((e && e.message) || '').toLowerCase();
375
377
  return /closed|reset|broken pipe|not connected|disconnect|write after end|socket|econnreset|epipe|connection/.test(m);
376
378
  }
@@ -0,0 +1,358 @@
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
+ // Times out after 10s: on a half-open pipe Discord can ack the socket write
232
+ // but never send the nonce reply, and without a deadline that await would
233
+ // hang forever — freezing the daemon's presence on a stale frame, with the
234
+ // watchdog blind because `connected` still reads true.
235
+ request(cmd, args, timeoutMs = 10_000) {
236
+ return new Promise((resolve, reject) => {
237
+ if (!this.socket) {
238
+ const err = new Error('Not connected');
239
+ err.code = 'ENOTCONN';
240
+ reject(err);
241
+ return;
242
+ }
243
+ const nonce = randomUUID();
244
+ const timer = setTimeout(() => {
245
+ this._pending.delete(nonce);
246
+ const err = new Error(`No reply to ${cmd} within ${timeoutMs}ms`);
247
+ err.code = 'ETIMEDOUT';
248
+ reject(err);
249
+ }, timeoutMs);
250
+ if (timer.unref) timer.unref();
251
+ this._pending.set(nonce, { resolve, reject, timer });
252
+ this._send(OP_FRAME, { cmd, args, nonce });
253
+ });
254
+ }
255
+
256
+ _onData(chunk) {
257
+ let frames;
258
+ try {
259
+ frames = this._decode(chunk);
260
+ } catch {
261
+ return;
262
+ }
263
+ for (const { op, data } of frames) this._onFrame(op, data);
264
+ }
265
+
266
+ _onFrame(op, msg) {
267
+ if (op === OP_PING) {
268
+ this._send(OP_PONG, msg);
269
+ return;
270
+ }
271
+ if (op === OP_CLOSE) {
272
+ this._onClose(msg);
273
+ return;
274
+ }
275
+ if (op !== OP_FRAME || !msg) return;
276
+
277
+ if (msg.cmd === 'DISPATCH' && msg.evt === 'READY') {
278
+ this.user = this._buildUser(msg.data?.user || {});
279
+ this._connected = true;
280
+ if (this._readyResolve) this._readyResolve();
281
+ return;
282
+ }
283
+
284
+ // Nonce-matched response to a request() (e.g. SET_ACTIVITY).
285
+ if (msg.nonce && this._pending.has(msg.nonce)) {
286
+ const { resolve, reject, timer } = this._pending.get(msg.nonce);
287
+ clearTimeout(timer);
288
+ this._pending.delete(msg.nonce);
289
+ if (msg.evt === 'ERROR') {
290
+ const err = new Error(msg.data?.message || 'Discord RPC error');
291
+ err.code = msg.data?.code;
292
+ reject(err);
293
+ } else {
294
+ resolve(msg);
295
+ }
296
+ }
297
+ }
298
+
299
+ // Wrap the READY user payload with the activity methods the daemon calls on
300
+ // `client.user`. Spreading the raw fields preserves `.username` (and id, etc).
301
+ _buildUser(raw) {
302
+ return {
303
+ ...raw,
304
+ setActivity: (activity, pid) => this.request('SET_ACTIVITY', formatActivity(activity, pid)),
305
+ clearActivity: (pid) => this.request('SET_ACTIVITY', { pid: pid ?? process?.pid ?? 0 }),
306
+ };
307
+ }
308
+
309
+ _onClose(reason) {
310
+ const wasConnected = this._connected || !!this.socket;
311
+ // Fail any in-flight requests so awaiters don't hang forever.
312
+ for (const { reject, timer } of this._pending.values()) {
313
+ clearTimeout(timer);
314
+ const err = new Error(typeof reason === 'string' ? reason : 'Connection closed');
315
+ err.code = 'ECONNRESET';
316
+ reject(err);
317
+ }
318
+ this._pending.clear();
319
+ if (this._readyReject && !this._connected) {
320
+ const err = new Error('Connection closed before ready');
321
+ err.code = 'ECONNRESET';
322
+ this._readyReject(err);
323
+ }
324
+ this._readyResolve = this._readyReject = null;
325
+ this._teardownSocket();
326
+ this._connected = false;
327
+ if (wasConnected) this.emit('disconnected');
328
+ }
329
+
330
+ _teardownSocket() {
331
+ if (!this.socket) return;
332
+ try {
333
+ this.socket.removeAllListeners();
334
+ this.socket.destroy();
335
+ } catch {
336
+ // already gone
337
+ }
338
+ this.socket = null;
339
+ }
340
+
341
+ destroy() {
342
+ // Best-effort close; don't emit 'disconnected' on an explicit teardown
343
+ // (the daemon calls destroy() itself and manages its own reconnect).
344
+ this._readyResolve = this._readyReject = null;
345
+ // Reject in-flight requests (mirrors _onClose) — a pushPresence parked on
346
+ // `await setActivity` when the watchdog tears the client down must settle,
347
+ // not leak as a forever-pending promise.
348
+ for (const { reject, timer } of this._pending.values()) {
349
+ clearTimeout(timer);
350
+ const err = new Error('Client destroyed');
351
+ err.code = 'ECONNRESET';
352
+ reject(err);
353
+ }
354
+ this._pending.clear();
355
+ this._teardownSocket();
356
+ this._connected = false;
357
+ }
358
+ }
Binary file
@@ -30,6 +30,12 @@
30
30
  let churnSeries = []; // [{ d: Date, add, rem }] — for the churn-sparkline tooltip
31
31
 
32
32
  // ── Utilities ───────────────────────────────────────────
33
+ // Escape before any innerHTML interpolation: project/file/command/model
34
+ // names come from the aggregate, i.e. ultimately from directory and file
35
+ // names on disk — a repo named `<img onerror=…>` must render, not run.
36
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => (
37
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
38
+ ));
33
39
  const fmtH = (ms) => {
34
40
  if (!ms) return '0h';
35
41
  const h = ms / 3_600_000;
@@ -232,10 +238,10 @@
232
238
  if (r.onClick) tr.classList.add('clickable');
233
239
  const ico = r.color ? '<span class="ico" style="background:' + r.color + '"></span>' : '';
234
240
  const nameHtml = opts.mono
235
- ? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + r.name + '</code>'
236
- : ico + r.name;
241
+ ? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + esc(r.name) + '</code>'
242
+ : ico + esc(r.name);
237
243
  tr.innerHTML = '<td class="name">' + nameHtml + '</td>' +
238
- '<td class="val">' + r.val + (r.unit ? '<span class="u">' + r.unit + '</span>' : '') + '</td>';
244
+ '<td class="val">' + esc(r.val) + (r.unit ? '<span class="u">' + esc(r.unit) + '</span>' : '') + '</td>';
239
245
  if (r.onClick) tr.addEventListener('click', r.onClick);
240
246
  tbl.appendChild(tr);
241
247
  });
@@ -276,7 +282,7 @@
276
282
  const w = Math.max(2, (cost / total) * 100);
277
283
  const row = document.createElement('div');
278
284
  row.className = 'cost-bar';
279
- row.innerHTML = '<span class="name">' + model + '</span>' +
285
+ row.innerHTML = '<span class="name">' + esc(model) + '</span>' +
280
286
  '<span class="track"><span class="fill" style="width:' + w.toFixed(0) + '%"></span></span>' +
281
287
  '<span class="val">' + fmtCost(cost) + '</span>';
282
288
  bars.appendChild(row);
@@ -303,7 +309,7 @@
303
309
  const row = document.createElement('div');
304
310
  row.className = 'row';
305
311
  row.innerHTML = '<span class="swatch" style="background:' + (LANGS[name] || '#888') + '"></span>' +
306
- '<span class="name">' + name + '</span>' +
312
+ '<span class="name">' + esc(name) + '</span>' +
307
313
  '<span class="val">' + fmtN(v.edits) + ' edits · ' + fmtN(v.files) + ' files</span>';
308
314
  list.appendChild(row);
309
315
  }
@@ -337,7 +343,7 @@
337
343
  const isCurrent = i === onAir;
338
344
  li.className = isCurrent ? 'current' : f.passes ? 'live' : 'skip';
339
345
  const summary = f.passes ? ((f.details || '—') + (f.state ? ' · ' + f.state : '')) : (f.details || '—');
340
- li.innerHTML = '<span class="pip"></span><span class="frame-text">' + summary + '</span>';
346
+ li.innerHTML = '<span class="pip"></span><span class="frame-text">' + esc(summary) + '</span>';
341
347
  ul.appendChild(li);
342
348
  });
343
349
  }
@@ -30,7 +30,20 @@ function parseUrl(rawUrl) {
30
30
  return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
31
31
  }
32
32
 
33
+ // Loopback-only Host allowlist. Binding to 127.0.0.1 blocks the LAN, but not
34
+ // DNS rebinding: a malicious page can point its own hostname at 127.0.0.1 and
35
+ // become "same-origin" with this server, then read /api/export.json. Rejecting
36
+ // non-local Host headers closes that — browsers always send the page's host.
37
+ function isLocalHost(host) {
38
+ const h = String(host || '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
39
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
40
+ }
41
+
33
42
  const server = createServer((req, res) => {
43
+ if (!isLocalHost(req.headers.host)) {
44
+ res.writeHead(403, JSON_HEADERS).end(JSON.stringify({ error: 'forbidden' }));
45
+ return;
46
+ }
34
47
  const { path, query } = parseUrl(req.url);
35
48
  const key = `${req.method} ${path}`;
36
49
 
package/src/state.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  closeSync,
9
9
  unlinkSync,
10
10
  statSync,
11
+ fstatSync,
11
12
  } from 'node:fs';
12
13
  import { basename } from 'node:path';
13
14
  import { STATE_PATH, STATE_DIR } from './paths.js';
@@ -110,11 +111,24 @@ function acquireLock() {
110
111
 
111
112
  function releaseLock(fd) {
112
113
  if (fd === null) return;
114
+ // Only unlink the lock if the path still points at OUR lock file. If this
115
+ // process somehow held it past LOCK_STALE_MS, a sibling has reclaimed the
116
+ // path (unlink + fresh 'wx' create); deleting that by path would collapse
117
+ // mutual exclusion for a third writer. Inode equality proves ownership.
118
+ let ours;
119
+ try {
120
+ const a = fstatSync(fd);
121
+ const b = statSync(LOCK_PATH);
122
+ ours = a.ino === b.ino && a.dev === b.dev;
123
+ } catch {
124
+ ours = false; // lock already gone — nothing to unlink
125
+ }
113
126
  try {
114
127
  closeSync(fd);
115
128
  } catch {
116
129
  /* already closed */
117
130
  }
131
+ if (!ours) return;
118
132
  try {
119
133
  unlinkSync(LOCK_PATH);
120
134
  } catch {
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.6';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {