vexp-mcp 2.0.19 → 2.0.21
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 +110 -16
- package/dist/index.js +10 -4
- package/package.json +1 -1
package/dist/daemon-client.js
CHANGED
|
@@ -12,54 +12,101 @@ export class DaemonClient {
|
|
|
12
12
|
explicitSocketPath;
|
|
13
13
|
requestCounter = 0;
|
|
14
14
|
sessionId;
|
|
15
|
-
|
|
15
|
+
/** Epoch ms of the last daemon auto-spawn attempt. Cooldown-gated rather than
|
|
16
|
+
* one-shot so a long-lived HTTP MCP server (the autonomous Codex supervisor)
|
|
17
|
+
* can resurrect the daemon repeatedly when VS Code is closed and the daemon
|
|
18
|
+
* exits — not only on the first connection of its lifetime. */
|
|
19
|
+
lastSpawnAttemptMs = 0;
|
|
16
20
|
static MAX_RETRIES = 3;
|
|
17
21
|
static RETRY_DELAY_MS = 1500;
|
|
22
|
+
static SPAWN_COOLDOWN_MS = 10_000;
|
|
18
23
|
static RETRYABLE_CODES = ["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"];
|
|
19
|
-
|
|
24
|
+
/** Workspace root used to (re)spawn the daemon and, when no socket is
|
|
25
|
+
* resolvable, to compute the expected socket/pipe path. Set by the HTTP
|
|
26
|
+
* transport so a per-workspace request can resurrect *its* daemon even when
|
|
27
|
+
* VEXP_WORKSPACE is not in the supervisor's env. */
|
|
28
|
+
spawnWorkspaceRoot;
|
|
29
|
+
constructor(socketPath, sessionId, spawnWorkspaceRoot) {
|
|
20
30
|
// When a caller passes an explicit path we honor it for the client's
|
|
21
31
|
// lifetime (back-compat for tests and one-shot CLI usage). When omitted,
|
|
22
32
|
// we re-resolve via the registry on every connect so a long-lived HTTP
|
|
23
33
|
// MCP server can follow daemons that start/stop while it's up.
|
|
24
34
|
this.explicitSocketPath = socketPath;
|
|
25
35
|
this.sessionId = sessionId ?? randomUUID();
|
|
36
|
+
this.spawnWorkspaceRoot = spawnWorkspaceRoot;
|
|
26
37
|
}
|
|
27
38
|
/**
|
|
28
|
-
* On
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
39
|
+
* On a connection failure, attempt to start the daemon (idempotent lato
|
|
40
|
+
* Rust). Cooldown-gated so a long-lived HTTP MCP server keeps the workspace
|
|
41
|
+
* daemon alive without VS Code: when the daemon dies, the next request that
|
|
42
|
+
* fails re-spawns it (after SPAWN_COOLDOWN_MS). Silent: success = next retry
|
|
43
|
+
* connects; failure = the ordinary retry/error path surfaces a clear error
|
|
44
|
+
* with the registry candidates and the daemon log tail. Disable via
|
|
45
|
+
* VEXP_NO_AUTOSTART=1.
|
|
32
46
|
*/
|
|
33
47
|
async maybeSpawnDaemon() {
|
|
34
|
-
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (now - this.lastSpawnAttemptMs < DaemonClient.SPAWN_COOLDOWN_MS)
|
|
35
50
|
return;
|
|
36
|
-
this.
|
|
51
|
+
this.lastSpawnAttemptMs = now;
|
|
37
52
|
if (process.env.VEXP_NO_AUTOSTART === "1")
|
|
38
53
|
return;
|
|
39
|
-
const workspaceRoot = process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
|
|
54
|
+
const workspaceRoot = this.spawnWorkspaceRoot ?? process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
|
|
40
55
|
if (!fs.existsSync(path.join(workspaceRoot, ".vexp", "manifest.json")))
|
|
41
56
|
return;
|
|
42
57
|
const binary = resolveVexpCoreBinary();
|
|
43
58
|
if (!binary)
|
|
44
59
|
return;
|
|
45
60
|
await new Promise((resolve) => {
|
|
61
|
+
// Propagate workspace + home so the spawned daemon resolves the right
|
|
62
|
+
// index and license dir even when the MCP server was launched from an
|
|
63
|
+
// unrelated cwd / a stripped env (e.g. the detached HTTP supervisor).
|
|
64
|
+
const env = binaryEnvFor(binary);
|
|
65
|
+
env.VEXP_WORKSPACE = workspaceRoot;
|
|
46
66
|
const child = spawn(binary, ["daemon-cmd", "start"], {
|
|
47
67
|
cwd: workspaceRoot,
|
|
48
68
|
stdio: "ignore",
|
|
49
69
|
detached: true,
|
|
50
|
-
env
|
|
70
|
+
env,
|
|
51
71
|
});
|
|
52
|
-
|
|
72
|
+
// Detach: the daemon (a grandchild of daemon-cmd) must outlive us.
|
|
73
|
+
child.unref();
|
|
74
|
+
let settled = false;
|
|
75
|
+
const done = () => { if (!settled) {
|
|
76
|
+
settled = true;
|
|
77
|
+
resolve();
|
|
78
|
+
} };
|
|
53
79
|
child.on("exit", done);
|
|
54
80
|
child.on("error", done);
|
|
55
|
-
// Cap total wait at 4s: Rust daemon-cmd itself waits 2s for socket.
|
|
81
|
+
// Cap total wait at 4s: Rust daemon-cmd itself waits ~2s for the socket.
|
|
56
82
|
setTimeout(done, 4000);
|
|
57
83
|
});
|
|
58
84
|
}
|
|
85
|
+
/** Tail of the daemon log files, for actionable connection errors. */
|
|
86
|
+
daemonLogTail(maxLines = 12) {
|
|
87
|
+
const workspaceRoot = this.spawnWorkspaceRoot ?? process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
|
|
88
|
+
for (const name of ["daemon.log", "vexp.log"]) {
|
|
89
|
+
try {
|
|
90
|
+
const p = path.join(workspaceRoot, ".vexp", name);
|
|
91
|
+
const lines = fs.readFileSync(p, "utf-8").trimEnd().split("\n");
|
|
92
|
+
if (lines.length && lines.some((l) => l.trim())) {
|
|
93
|
+
return `\n--- ${name} (last ${Math.min(maxLines, lines.length)} lines) ---\n` +
|
|
94
|
+
lines.slice(-maxLines).join("\n");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* missing */ }
|
|
98
|
+
}
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
59
101
|
/** Resolve socket path at request time to survive daemon restarts. */
|
|
60
102
|
get socketPath() {
|
|
61
103
|
if (this.explicitSocketPath)
|
|
62
104
|
return this.explicitSocketPath;
|
|
105
|
+
// HTTP per-workspace client: target this workspace's deterministic socket
|
|
106
|
+
// so a freshly respawned daemon is reconnected even if the registry was
|
|
107
|
+
// stale/missing when the request arrived.
|
|
108
|
+
if (this.spawnWorkspaceRoot)
|
|
109
|
+
return socketPathForWorkspace(this.spawnWorkspaceRoot);
|
|
63
110
|
return resolveDaemonSocket() ?? getDefaultSocketPath();
|
|
64
111
|
}
|
|
65
112
|
static isRetryableError(err) {
|
|
@@ -78,8 +125,8 @@ export class DaemonClient {
|
|
|
78
125
|
catch (err) {
|
|
79
126
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
80
127
|
if (attempt < DaemonClient.MAX_RETRIES && DaemonClient.isRetryableError(lastError)) {
|
|
81
|
-
//
|
|
82
|
-
//
|
|
128
|
+
// On the first failure, (re)start the daemon (cooldown-gated so a
|
|
129
|
+
// long-lived supervisor can resurrect it whenever it dies).
|
|
83
130
|
if (attempt === 0) {
|
|
84
131
|
await this.maybeSpawnDaemon();
|
|
85
132
|
}
|
|
@@ -89,10 +136,21 @@ export class DaemonClient {
|
|
|
89
136
|
await new Promise((r) => setTimeout(r, DaemonClient.RETRY_DELAY_MS));
|
|
90
137
|
continue;
|
|
91
138
|
}
|
|
92
|
-
throw lastError;
|
|
139
|
+
throw this.augmentDaemonError(lastError);
|
|
93
140
|
}
|
|
94
141
|
}
|
|
95
|
-
throw lastError;
|
|
142
|
+
throw this.augmentDaemonError(lastError);
|
|
143
|
+
}
|
|
144
|
+
/** Append the daemon log tail to a connection error so a failure that
|
|
145
|
+
* survived auto-spawn + retries is actionable instead of opaque. */
|
|
146
|
+
augmentDaemonError(err) {
|
|
147
|
+
if (!DaemonClient.isRetryableError(err))
|
|
148
|
+
return err;
|
|
149
|
+
const tail = this.daemonLogTail();
|
|
150
|
+
if (tail && !err.message.includes("--- daemon.log") && !err.message.includes("--- vexp.log")) {
|
|
151
|
+
err.message += tail;
|
|
152
|
+
}
|
|
153
|
+
return err;
|
|
96
154
|
}
|
|
97
155
|
/**
|
|
98
156
|
* Call a tool with streaming support. Returns the final result.
|
|
@@ -505,6 +563,42 @@ export function resolveSocketByWorkspaceHash(hashPrefix) {
|
|
|
505
563
|
}
|
|
506
564
|
return null;
|
|
507
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Reverse lookup: hash prefix -> workspace root, from `~/.vexp/daemons.json`.
|
|
568
|
+
*
|
|
569
|
+
* Unlike resolveSocketByWorkspaceHash this does NOT require the socket to be
|
|
570
|
+
* live — it returns the workspace even for a crashed daemon whose stale
|
|
571
|
+
* registry entry survived (the common "VS Code closed, daemon child killed"
|
|
572
|
+
* case). The HTTP transport uses it to (re)spawn the *correct* workspace
|
|
573
|
+
* daemon on demand. fnvHash is one-way, so a gracefully-deregistered daemon
|
|
574
|
+
* (entry removed) is unrecoverable from the hash alone — returns null.
|
|
575
|
+
*/
|
|
576
|
+
export function resolveWorkspaceByHash(hashPrefix) {
|
|
577
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
578
|
+
if (!home)
|
|
579
|
+
return null;
|
|
580
|
+
let registry;
|
|
581
|
+
try {
|
|
582
|
+
registry = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "daemons.json"), "utf-8"));
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const target = hashPrefix.toLowerCase();
|
|
588
|
+
for (const ws of Object.keys(registry)) {
|
|
589
|
+
if (fnvHash(ws.toLowerCase()).slice(0, 8) === target)
|
|
590
|
+
return ws;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
/** Deterministic socket/pipe path for a workspace root — mirrors the Rust
|
|
595
|
+
* daemon's get_socket_path (vexp-core/src/utils.rs). */
|
|
596
|
+
function socketPathForWorkspace(ws) {
|
|
597
|
+
if (process.platform === "win32") {
|
|
598
|
+
return `\\\\.\\pipe\\vexp-${fnvHash(ws.toLowerCase()).slice(0, 8)}`;
|
|
599
|
+
}
|
|
600
|
+
return path.join(ws, ".vexp", "daemon.sock");
|
|
601
|
+
}
|
|
508
602
|
/** Human-friendly hint printed when a connection attempt fails — enumerates
|
|
509
603
|
* what we tried to make the failure actionable instead of misleading. */
|
|
510
604
|
function daemonRunHint() {
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createServer } from "http";
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import { DaemonClient, resolveSocketByWorkspaceHash } from "./daemon-client.js";
|
|
10
|
+
import { DaemonClient, resolveSocketByWorkspaceHash, resolveWorkspaceByHash } from "./daemon-client.js";
|
|
11
11
|
import { GET_CONTEXT_CAPSULE_DEFINITION, handleGetContextCapsule, } from "./tools/get-context-capsule.js";
|
|
12
12
|
import { GET_IMPACT_GRAPH_DEFINITION, handleGetImpactGraph, } from "./tools/get-impact-graph.js";
|
|
13
13
|
import { SEARCH_LOGIC_FLOW_DEFINITION, handleSearchLogicFlow, } from "./tools/search-logic-flow.js";
|
|
@@ -228,14 +228,20 @@ async function startHttp(fallbackDaemon, port) {
|
|
|
228
228
|
app.all("/ws/:hash/mcp", async (req, res) => {
|
|
229
229
|
const { hash } = req.params;
|
|
230
230
|
const socketPath = resolveSocketByWorkspaceHash(hash);
|
|
231
|
-
|
|
231
|
+
// Recover the workspace root from the registry even when the daemon is
|
|
232
|
+
// dead (stale entry survives a crash / VS Code close) so we can resurrect
|
|
233
|
+
// *that* workspace's daemon on demand — the autonomous supervisor.
|
|
234
|
+
const workspaceRoot = resolveWorkspaceByHash(hash);
|
|
235
|
+
if (!socketPath && !workspaceRoot) {
|
|
232
236
|
res.status(503).json({
|
|
233
237
|
error: "NoDaemon",
|
|
234
|
-
message: `No
|
|
238
|
+
message: `No daemon and no known workspace for hash ${hash}. Run 'vexp setup' or 'vexp daemon-cmd start' in the project.`,
|
|
235
239
|
});
|
|
236
240
|
return;
|
|
237
241
|
}
|
|
238
|
-
|
|
242
|
+
// Pass the workspace so DaemonClient targets the right socket and, on a
|
|
243
|
+
// connection failure, (re)spawns the daemon for this workspace.
|
|
244
|
+
const daemon = new DaemonClient(socketPath ?? undefined, undefined, workspaceRoot ?? undefined);
|
|
239
245
|
await handleMcpRequest(req, res, hash, daemon);
|
|
240
246
|
});
|
|
241
247
|
// Legacy route — kept for backward compat. Uses the fallback daemon,
|