vexp-mcp 2.0.3 → 2.0.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/dist/daemon-client.js +241 -26
- package/dist/index.js +11 -101
- package/package.json +1 -1
package/dist/daemon-client.js
CHANGED
|
@@ -1,22 +1,67 @@
|
|
|
1
1
|
import * as net from "net";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
4
6
|
import { randomUUID } from "crypto";
|
|
5
7
|
/**
|
|
6
8
|
* Client per comunicare con il vexp-core daemon via Unix socket / Named pipe.
|
|
7
9
|
* Protocol: JSON lines (newline-delimited JSON).
|
|
8
10
|
*/
|
|
9
11
|
export class DaemonClient {
|
|
10
|
-
|
|
12
|
+
explicitSocketPath;
|
|
11
13
|
requestCounter = 0;
|
|
12
14
|
sessionId;
|
|
15
|
+
daemonSpawnAttempted = false;
|
|
13
16
|
static MAX_RETRIES = 3;
|
|
14
17
|
static RETRY_DELAY_MS = 1500;
|
|
15
18
|
static RETRYABLE_CODES = ["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"];
|
|
16
19
|
constructor(socketPath, sessionId) {
|
|
17
|
-
|
|
20
|
+
// When a caller passes an explicit path we honor it for the client's
|
|
21
|
+
// lifetime (back-compat for tests and one-shot CLI usage). When omitted,
|
|
22
|
+
// we re-resolve via the registry on every connect so a long-lived HTTP
|
|
23
|
+
// MCP server can follow daemons that start/stop while it's up.
|
|
24
|
+
this.explicitSocketPath = socketPath;
|
|
18
25
|
this.sessionId = sessionId ?? randomUUID();
|
|
19
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* On the first connection failure, attempt to start the daemon once
|
|
29
|
+
* (idempotent lato Rust). Silent: success = next retry connects;
|
|
30
|
+
* failure = the ordinary retry/error path still surfaces a clear error
|
|
31
|
+
* with the registry candidates. Controlled by VEXP_NO_AUTOSTART=1.
|
|
32
|
+
*/
|
|
33
|
+
async maybeSpawnDaemon() {
|
|
34
|
+
if (this.daemonSpawnAttempted)
|
|
35
|
+
return;
|
|
36
|
+
this.daemonSpawnAttempted = true;
|
|
37
|
+
if (process.env.VEXP_NO_AUTOSTART === "1")
|
|
38
|
+
return;
|
|
39
|
+
const workspaceRoot = process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
|
|
40
|
+
if (!fs.existsSync(path.join(workspaceRoot, ".vexp", "manifest.json")))
|
|
41
|
+
return;
|
|
42
|
+
const binary = resolveVexpCoreBinary();
|
|
43
|
+
if (!binary)
|
|
44
|
+
return;
|
|
45
|
+
await new Promise((resolve) => {
|
|
46
|
+
const child = spawn(binary, ["daemon-cmd", "start"], {
|
|
47
|
+
cwd: workspaceRoot,
|
|
48
|
+
stdio: "ignore",
|
|
49
|
+
detached: true,
|
|
50
|
+
env: binaryEnvFor(binary),
|
|
51
|
+
});
|
|
52
|
+
const done = () => { resolve(); };
|
|
53
|
+
child.on("exit", done);
|
|
54
|
+
child.on("error", done);
|
|
55
|
+
// Cap total wait at 4s: Rust daemon-cmd itself waits 2s for socket.
|
|
56
|
+
setTimeout(done, 4000);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Resolve socket path at request time to survive daemon restarts. */
|
|
60
|
+
get socketPath() {
|
|
61
|
+
if (this.explicitSocketPath)
|
|
62
|
+
return this.explicitSocketPath;
|
|
63
|
+
return resolveDaemonSocket() ?? getDefaultSocketPath();
|
|
64
|
+
}
|
|
20
65
|
static isRetryableError(err) {
|
|
21
66
|
const msg = err.message;
|
|
22
67
|
return DaemonClient.RETRYABLE_CODES.some((code) => msg.includes(code));
|
|
@@ -33,6 +78,11 @@ export class DaemonClient {
|
|
|
33
78
|
catch (err) {
|
|
34
79
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
35
80
|
if (attempt < DaemonClient.MAX_RETRIES && DaemonClient.isRetryableError(lastError)) {
|
|
81
|
+
// First failure: try to auto-start the daemon (one-shot).
|
|
82
|
+
// Subsequent failures skip the spawn and just retry.
|
|
83
|
+
if (attempt === 0) {
|
|
84
|
+
await this.maybeSpawnDaemon();
|
|
85
|
+
}
|
|
36
86
|
console.error(`[vexp-mcp] Daemon connection failed (${lastError.message}), ` +
|
|
37
87
|
`retrying in ${DaemonClient.RETRY_DELAY_MS}ms ` +
|
|
38
88
|
`(attempt ${attempt + 1}/${DaemonClient.MAX_RETRIES})`);
|
|
@@ -119,7 +169,7 @@ export class DaemonClient {
|
|
|
119
169
|
}
|
|
120
170
|
});
|
|
121
171
|
client.on("error", (err) => {
|
|
122
|
-
settle(() => reject(new Error(`Daemon connection error: ${err.message}.
|
|
172
|
+
settle(() => reject(new Error(`Daemon connection error: ${err.message}. ${daemonRunHint()}`)));
|
|
123
173
|
});
|
|
124
174
|
client.on("close", () => {
|
|
125
175
|
settle(() => reject(new Error("Daemon connection closed unexpectedly")));
|
|
@@ -178,7 +228,7 @@ export class DaemonClient {
|
|
|
178
228
|
}
|
|
179
229
|
});
|
|
180
230
|
client.on("error", (err) => {
|
|
181
|
-
settle(() => reject(new Error(`Daemon connection error: ${err.message}.
|
|
231
|
+
settle(() => reject(new Error(`Daemon connection error: ${err.message}. ${daemonRunHint()}`)));
|
|
182
232
|
});
|
|
183
233
|
client.on("close", () => {
|
|
184
234
|
settle(() => reject(new Error("Daemon connection closed unexpectedly")));
|
|
@@ -198,6 +248,64 @@ export class DaemonClient {
|
|
|
198
248
|
}
|
|
199
249
|
}
|
|
200
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Locate the vexp-core binary from the MCP server's own filesystem
|
|
253
|
+
* position. Supports three layouts:
|
|
254
|
+
* - Env override: VEXP_CORE_PATH
|
|
255
|
+
* - CLI npm install: the MCP bundle lives in vexp-cli/mcp/ with a
|
|
256
|
+
* sibling @vexp/core-<plat> package at ../../@vexp/core-<plat>/bin/
|
|
257
|
+
* - VSCode extension: binaries/vexp-core-<plat>/ alongside dist/
|
|
258
|
+
* - Dev fallback: target/release/vexp-core in the monorepo
|
|
259
|
+
* Returns null if nothing matches.
|
|
260
|
+
*/
|
|
261
|
+
function resolveVexpCoreBinary() {
|
|
262
|
+
if (process.env.VEXP_CORE_PATH && fs.existsSync(process.env.VEXP_CORE_PATH)) {
|
|
263
|
+
return process.env.VEXP_CORE_PATH;
|
|
264
|
+
}
|
|
265
|
+
// __dirname may not exist in ESM — derive it from import.meta.url fallback.
|
|
266
|
+
let scriptDir;
|
|
267
|
+
try {
|
|
268
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
269
|
+
scriptDir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
scriptDir = process.cwd();
|
|
273
|
+
}
|
|
274
|
+
const plat = `${process.platform}-${process.arch === "arm64" ? "arm64" : "x64"}`;
|
|
275
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
276
|
+
const candidates = [
|
|
277
|
+
// CLI npm install: mcp-server.cjs → ../../@vexp/core-<plat>/bin/vexp-core
|
|
278
|
+
path.resolve(scriptDir, "..", "..", "@vexp", `core-${plat}`, "bin", `vexp-core${ext}`),
|
|
279
|
+
// Same but relative from dist/mcp-server.cjs inside vexp-cli
|
|
280
|
+
path.resolve(scriptDir, "..", "node_modules", "@vexp", `core-${plat}`, "bin", `vexp-core${ext}`),
|
|
281
|
+
// VSCode extension: dist/mcp-server.cjs → ../binaries/vexp-core-<plat>/vexp-core
|
|
282
|
+
path.resolve(scriptDir, "..", "binaries", `vexp-core-${plat}`, `vexp-core${ext}`),
|
|
283
|
+
// Dev fallback: monorepo target/release
|
|
284
|
+
path.resolve(scriptDir, "..", "..", "..", "target", "release", `vexp-core${ext}`),
|
|
285
|
+
];
|
|
286
|
+
for (const c of candidates) {
|
|
287
|
+
if (fs.existsSync(c))
|
|
288
|
+
return c;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Build env for spawning vexp-core: prepend binary dir to LD_LIBRARY_PATH
|
|
294
|
+
* (Linux) / DYLD_* (macOS) so the loader finds bundled libggml/libllama
|
|
295
|
+
* even when the binary's RPATH is missing (e.g. older VSIX artifacts).
|
|
296
|
+
*/
|
|
297
|
+
function binaryEnvFor(binaryPath) {
|
|
298
|
+
const dir = path.dirname(binaryPath);
|
|
299
|
+
const env = { ...process.env };
|
|
300
|
+
if (process.platform === "linux") {
|
|
301
|
+
env.LD_LIBRARY_PATH = env.LD_LIBRARY_PATH ? `${dir}:${env.LD_LIBRARY_PATH}` : dir;
|
|
302
|
+
}
|
|
303
|
+
else if (process.platform === "darwin") {
|
|
304
|
+
env.DYLD_LIBRARY_PATH = env.DYLD_LIBRARY_PATH ? `${dir}:${env.DYLD_LIBRARY_PATH}` : dir;
|
|
305
|
+
env.DYLD_FALLBACK_LIBRARY_PATH = env.DYLD_FALLBACK_LIBRARY_PATH ? `${dir}:${env.DYLD_FALLBACK_LIBRARY_PATH}` : dir;
|
|
306
|
+
}
|
|
307
|
+
return env;
|
|
308
|
+
}
|
|
201
309
|
/** FNV-1a 64-bit hash — must match vexp-core/src/utils.rs md5_hash() */
|
|
202
310
|
function fnvHash(input) {
|
|
203
311
|
let hash = BigInt("0xcbf29ce484222325");
|
|
@@ -239,38 +347,145 @@ function discoverWorkspaceRoot() {
|
|
|
239
347
|
}
|
|
240
348
|
return cwd;
|
|
241
349
|
}
|
|
242
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Read `~/.vexp/daemons.json` and pick the best entry for the given workspace.
|
|
352
|
+
*
|
|
353
|
+
* The Rust daemon registers itself here on startup on ALL platforms (main.rs
|
|
354
|
+
* writes `{workspace_root → socket_path}`). This is the authoritative way to
|
|
355
|
+
* find a live daemon when the MCP server was spawned from an unrelated cwd
|
|
356
|
+
* (typical for Claude Code, Cursor, Windsurf, GitHub Copilot — they start
|
|
357
|
+
* MCP servers from ~, from the file's dir, etc., never guaranteed to be the
|
|
358
|
+
* workspace root).
|
|
359
|
+
*
|
|
360
|
+
* Selection policy:
|
|
361
|
+
* 1. Exact workspace match
|
|
362
|
+
* 2. Longest workspace that is a prefix of `workspaceHint` (handles
|
|
363
|
+
* submodule / nested-directory cwd pointing at a parent daemon)
|
|
364
|
+
* 3. If registry has exactly one entry, use it
|
|
365
|
+
* 4. Otherwise `null` (caller should error clearly)
|
|
366
|
+
*/
|
|
367
|
+
function pickFromDaemonRegistry(workspaceHint) {
|
|
368
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
369
|
+
if (!home)
|
|
370
|
+
return null;
|
|
371
|
+
const registryPath = path.join(home, ".vexp", "daemons.json");
|
|
372
|
+
let registry;
|
|
373
|
+
try {
|
|
374
|
+
registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
// Filter out stale entries: SIGKILL'd daemons don't run their Drop
|
|
380
|
+
// handler so the registry accumulates ghost workspaces. Keep only
|
|
381
|
+
// entries whose socket file still exists (Windows named pipes are
|
|
382
|
+
// always exposed by the kernel, so skip the file check there).
|
|
383
|
+
const allEntries = Object.entries(registry);
|
|
384
|
+
const entries = process.platform === "win32"
|
|
385
|
+
? allEntries
|
|
386
|
+
: allEntries.filter(([, sock]) => {
|
|
387
|
+
try {
|
|
388
|
+
return fs.statSync(sock).isSocket();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
if (entries.length === 0)
|
|
395
|
+
return null;
|
|
396
|
+
const hintNorm = workspaceHint.toLowerCase();
|
|
397
|
+
for (const [ws, sock] of entries) {
|
|
398
|
+
if (ws.toLowerCase() === hintNorm)
|
|
399
|
+
return sock;
|
|
400
|
+
}
|
|
401
|
+
let bestWs = "";
|
|
402
|
+
let bestSock = null;
|
|
403
|
+
for (const [ws, sock] of entries) {
|
|
404
|
+
const wsNorm = ws.toLowerCase();
|
|
405
|
+
if (hintNorm.startsWith(wsNorm + path.sep) || hintNorm === wsNorm) {
|
|
406
|
+
if (wsNorm.length > bestWs.length) {
|
|
407
|
+
bestWs = wsNorm;
|
|
408
|
+
bestSock = sock;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (bestSock)
|
|
413
|
+
return bestSock;
|
|
414
|
+
if (entries.length === 1)
|
|
415
|
+
return entries[0][1];
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Resolve the daemon socket path / named pipe, cross-platform, using:
|
|
420
|
+
* 1. `VEXP_SOCKET` env — explicit override (highest precedence)
|
|
421
|
+
* 2. Workspace-local socket if it exists (`<workspaceRoot>/.vexp/daemon.sock`)
|
|
422
|
+
* 3. Global daemon registry `~/.vexp/daemons.json` (written by the Rust
|
|
423
|
+
* daemon at startup on all platforms)
|
|
424
|
+
* 4. Platform-specific fallback (Windows hash pipe, otherwise `null` →
|
|
425
|
+
* caller surfaces a clear "no daemon" error)
|
|
426
|
+
*
|
|
427
|
+
* Does NOT fall back to `~/.vexp/daemon.sock` — the daemon never writes
|
|
428
|
+
* there; that path is a dead-end that masks the real error.
|
|
429
|
+
*/
|
|
430
|
+
export function resolveDaemonSocket() {
|
|
431
|
+
const explicit = process.env["VEXP_SOCKET"];
|
|
432
|
+
if (explicit)
|
|
433
|
+
return explicit;
|
|
243
434
|
const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
|
|
244
435
|
if (process.platform === "win32") {
|
|
245
|
-
//
|
|
436
|
+
// Workspace-specific pipe name file written by the daemon
|
|
246
437
|
const pipeFile = path.join(workspaceRoot, ".vexp", "daemon.pipe");
|
|
247
438
|
try {
|
|
248
439
|
const pipeName = fs.readFileSync(pipeFile, "utf-8").trim();
|
|
249
440
|
if (pipeName)
|
|
250
441
|
return pipeName;
|
|
251
442
|
}
|
|
252
|
-
catch { /*
|
|
253
|
-
//
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
259
|
-
const entries = Object.entries(registry);
|
|
260
|
-
if (entries.length > 0) {
|
|
261
|
-
const normalizedCwd = workspaceRoot.toLowerCase();
|
|
262
|
-
for (const [ws, pipe] of entries) {
|
|
263
|
-
if (ws.toLowerCase() === normalizedCwd)
|
|
264
|
-
return pipe;
|
|
265
|
-
}
|
|
266
|
-
return entries[0][1]; // fallback to first available
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
catch { }
|
|
270
|
-
}
|
|
271
|
-
// 3. Fallback: compute hash
|
|
443
|
+
catch { /* missing */ }
|
|
444
|
+
// Registry
|
|
445
|
+
const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
|
|
446
|
+
if (fromRegistry)
|
|
447
|
+
return fromRegistry;
|
|
448
|
+
// Hash-based last-ditch (daemon should always register, so this is rare)
|
|
272
449
|
const hash = fnvHash(workspaceRoot.toLowerCase());
|
|
273
450
|
return `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`;
|
|
274
451
|
}
|
|
452
|
+
// Unix: prefer workspace-local if the socket file actually exists
|
|
453
|
+
const cwdSocket = path.join(workspaceRoot, ".vexp", "daemon.sock");
|
|
454
|
+
if (fs.existsSync(cwdSocket))
|
|
455
|
+
return cwdSocket;
|
|
456
|
+
// Registry (Linux + macOS) — the piece that was missing
|
|
457
|
+
const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
|
|
458
|
+
if (fromRegistry)
|
|
459
|
+
return fromRegistry;
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
/** Back-compat wrapper: some callers expect a string (old behavior).
|
|
463
|
+
* Returns the resolved path or the nominal workspace path so the caller's
|
|
464
|
+
* connect attempt produces a meaningful ENOENT with the real path. */
|
|
465
|
+
function getDefaultSocketPath() {
|
|
466
|
+
const resolved = resolveDaemonSocket();
|
|
467
|
+
if (resolved)
|
|
468
|
+
return resolved;
|
|
469
|
+
const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
|
|
275
470
|
return path.join(workspaceRoot, ".vexp", "daemon.sock");
|
|
276
471
|
}
|
|
472
|
+
export { discoverWorkspaceRoot };
|
|
473
|
+
/** Human-friendly hint printed when a connection attempt fails — enumerates
|
|
474
|
+
* what we tried to make the failure actionable instead of misleading. */
|
|
475
|
+
function daemonRunHint() {
|
|
476
|
+
const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
|
|
477
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
478
|
+
const candidates = [];
|
|
479
|
+
if (process.env["VEXP_SOCKET"])
|
|
480
|
+
candidates.push(`VEXP_SOCKET=${process.env["VEXP_SOCKET"]}`);
|
|
481
|
+
candidates.push(`${workspaceRoot}/.vexp/daemon.sock`);
|
|
482
|
+
if (home) {
|
|
483
|
+
try {
|
|
484
|
+
const reg = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "daemons.json"), "utf-8"));
|
|
485
|
+
for (const [ws, sock] of Object.entries(reg))
|
|
486
|
+
candidates.push(`registry: ${ws} → ${sock}`);
|
|
487
|
+
}
|
|
488
|
+
catch { /* registry missing is expected when no daemon has run yet */ }
|
|
489
|
+
}
|
|
490
|
+
return `Tried: [${candidates.join(", ")}]. Run \`vexp setup\` in your project to start the daemon, or set VEXP_WORKSPACE / VEXP_SOCKET.`;
|
|
491
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -61,105 +61,11 @@ function allToolsEnabled() {
|
|
|
61
61
|
const lowered = v.trim().toLowerCase();
|
|
62
62
|
return lowered === "1" || lowered === "true" || lowered === "yes" || lowered === "on";
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const bytes = Buffer.from(input, "utf-8");
|
|
70
|
-
for (const byte of bytes) {
|
|
71
|
-
hash ^= BigInt(byte);
|
|
72
|
-
hash = (hash * prime) & mask;
|
|
73
|
-
}
|
|
74
|
-
return hash.toString(16);
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Walk up from cwd looking for .vexp/ or .git/ to find the workspace root.
|
|
78
|
-
* Used when VEXP_WORKSPACE is not set (e.g., Codex, Cursor, or other agents
|
|
79
|
-
* spawn the MCP server without passing the workspace path).
|
|
80
|
-
*/
|
|
81
|
-
function discoverWorkspaceRoot() {
|
|
82
|
-
const cwd = process.cwd();
|
|
83
|
-
// First pass: look for .vexp/ (strongest signal — daemon was started here)
|
|
84
|
-
let current = cwd;
|
|
85
|
-
while (true) {
|
|
86
|
-
if (fs.existsSync(path.join(current, ".vexp")))
|
|
87
|
-
return current;
|
|
88
|
-
const parent = path.dirname(current);
|
|
89
|
-
if (parent === current)
|
|
90
|
-
break;
|
|
91
|
-
current = parent;
|
|
92
|
-
}
|
|
93
|
-
// Second pass: look for .git/ (standard repo root)
|
|
94
|
-
current = cwd;
|
|
95
|
-
while (true) {
|
|
96
|
-
if (fs.existsSync(path.join(current, ".git")))
|
|
97
|
-
return current;
|
|
98
|
-
const parent = path.dirname(current);
|
|
99
|
-
if (parent === current)
|
|
100
|
-
break;
|
|
101
|
-
current = parent;
|
|
102
|
-
}
|
|
103
|
-
return cwd;
|
|
104
|
-
}
|
|
105
|
-
function getSocketPath() {
|
|
106
|
-
if (process.env.VEXP_SOCKET)
|
|
107
|
-
return process.env.VEXP_SOCKET;
|
|
108
|
-
const workspaceRoot = process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
|
|
109
|
-
if (process.platform === "win32") {
|
|
110
|
-
// 1. Try reading daemon.pipe from discovered workspace
|
|
111
|
-
const pipeFile = path.join(workspaceRoot, ".vexp", "daemon.pipe");
|
|
112
|
-
try {
|
|
113
|
-
const pipeName = fs.readFileSync(pipeFile, "utf-8").trim();
|
|
114
|
-
if (pipeName)
|
|
115
|
-
return pipeName;
|
|
116
|
-
}
|
|
117
|
-
catch { /* file doesn't exist */ }
|
|
118
|
-
// 2. Try global daemon registry (~/.vexp/daemons.json)
|
|
119
|
-
const fromRegistry = readDaemonRegistry(workspaceRoot);
|
|
120
|
-
if (fromRegistry)
|
|
121
|
-
return fromRegistry;
|
|
122
|
-
// 3. Fallback: compute hash (may mismatch if path differs from daemon's)
|
|
123
|
-
const normalized = workspaceRoot.toLowerCase();
|
|
124
|
-
const hash = fnvHash(normalized);
|
|
125
|
-
return `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`;
|
|
126
|
-
}
|
|
127
|
-
const cwdSocket = `${workspaceRoot}/.vexp/daemon.sock`;
|
|
128
|
-
if (fs.existsSync(cwdSocket))
|
|
129
|
-
return cwdSocket;
|
|
130
|
-
const home = process.env.HOME ?? "/tmp";
|
|
131
|
-
return `${home}/.vexp/daemon.sock`;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Read the global daemon registry (~/.vexp/daemons.json) to find a running daemon.
|
|
135
|
-
* Falls back to: exact workspace match → single daemon → first available.
|
|
136
|
-
*/
|
|
137
|
-
function readDaemonRegistry(workspaceRoot) {
|
|
138
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
139
|
-
if (!home)
|
|
140
|
-
return null;
|
|
141
|
-
const registryPath = path.join(home, ".vexp", "daemons.json");
|
|
142
|
-
try {
|
|
143
|
-
const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
144
|
-
const entries = Object.entries(registry);
|
|
145
|
-
if (entries.length === 0)
|
|
146
|
-
return null;
|
|
147
|
-
// Try exact match (case-insensitive on Windows)
|
|
148
|
-
const normalizedCwd = workspaceRoot.toLowerCase();
|
|
149
|
-
for (const [ws, pipe] of entries) {
|
|
150
|
-
if (ws.toLowerCase() === normalizedCwd)
|
|
151
|
-
return pipe;
|
|
152
|
-
}
|
|
153
|
-
// Single daemon running — use it directly
|
|
154
|
-
if (entries.length === 1)
|
|
155
|
-
return entries[0][1];
|
|
156
|
-
// Multiple daemons, no match — use first available
|
|
157
|
-
return entries[0][1];
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
64
|
+
// Socket path resolution + workspace discovery live in daemon-client.ts
|
|
65
|
+
// as the single source of truth (exported: resolveDaemonSocket,
|
|
66
|
+
// discoverWorkspaceRoot). The Rust daemon registers itself in
|
|
67
|
+
// ~/.vexp/daemons.json on all platforms, which is the authoritative
|
|
68
|
+
// lookup when an agent spawns the MCP server from an unrelated cwd.
|
|
163
69
|
function createMcpServer(daemon) {
|
|
164
70
|
const server = new Server({ name: "vexp-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
165
71
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
@@ -361,8 +267,12 @@ async function startHttp(daemon, port) {
|
|
|
361
267
|
}
|
|
362
268
|
async function main() {
|
|
363
269
|
const args = process.argv.slice(2);
|
|
364
|
-
|
|
365
|
-
|
|
270
|
+
// DaemonClient with no explicit socket: resolves via VEXP_SOCKET →
|
|
271
|
+
// workspace-local socket → ~/.vexp/daemons.json registry on every
|
|
272
|
+
// connect. A long-lived HTTP MCP server thus follows daemons that
|
|
273
|
+
// start/stop while it's up, and fresh stdio spawns pick the right
|
|
274
|
+
// daemon without restarting the MCP server.
|
|
275
|
+
const daemon = new DaemonClient();
|
|
366
276
|
const useHttp = args.includes("--http") || process.env.VEXP_HTTP === "1";
|
|
367
277
|
const port = parseInt(args.find((a) => a.startsWith("--port="))?.split("=")[1] ??
|
|
368
278
|
process.env.VEXP_PORT ??
|