claude-rpc 0.13.3 → 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 +1 -1
- package/package.json +6 -4
- package/src/community.js +11 -2
- package/src/daemon.js +3 -3
- package/src/discord-ipc.js +336 -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.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/community.js
CHANGED
|
@@ -53,6 +53,15 @@ export function osFamily() {
|
|
|
53
53
|
return 'linux';
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Per-report caps. These mirror the worker's validateReport limits — the
|
|
57
|
+
// client CLAMPS each delta to them so a large first-time backfill (a heavy
|
|
58
|
+
// user's whole lifetime total on the very first report) STREAMS over multiple
|
|
59
|
+
// flushes instead of being rejected. Without this, anyone with >5B lifetime
|
|
60
|
+
// tokens would 400 forever (the cursor never advances on a rejected report) and
|
|
61
|
+
// be silently dropped from the community totals.
|
|
62
|
+
const MAX_REPORT_SESSIONS = 100_000;
|
|
63
|
+
const MAX_REPORT_TOKENS = 5_000_000_000;
|
|
64
|
+
|
|
56
65
|
// Pure: given an aggregate and a cursor, produce the next payload. The
|
|
57
66
|
// worker's validateReport must accept this shape; if you add a field
|
|
58
67
|
// here, add it there too.
|
|
@@ -64,8 +73,8 @@ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }
|
|
|
64
73
|
+ (aggregate?.cacheWriteTokens || 0);
|
|
65
74
|
return {
|
|
66
75
|
instanceId,
|
|
67
|
-
sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
|
|
68
|
-
tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
|
|
76
|
+
sessionsDelta: Math.min(MAX_REPORT_SESSIONS, Math.max(0, sessions - (cursor.sessions || 0))),
|
|
77
|
+
tokensDelta: Math.min(MAX_REPORT_TOKENS, Math.max(0, tokens - (cursor.tokens || 0))),
|
|
69
78
|
version: VERSION,
|
|
70
79
|
osFamily: osFamily(),
|
|
71
80
|
ts: now,
|
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,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
|
|
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
|
+
}
|