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 +1 -1
- package/package.json +6 -4
- package/src/daemon.js +6 -4
- package/src/discord-ipc.js +358 -0
- package/src/leaderboard.js +0 -0
- package/src/server/assets/dashboard.client.js +12 -6
- package/src/server/index.js +13 -0
- package/src/state.js +14 -0
- package/src/version.js +1 -1
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`.
|
|
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.
|
|
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 '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/leaderboard.js
CHANGED
|
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
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
}
|
package/src/server/index.js
CHANGED
|
@@ -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 {
|