vexp-mcp 2.0.20 → 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.
@@ -12,54 +12,101 @@ export class DaemonClient {
12
12
  explicitSocketPath;
13
13
  requestCounter = 0;
14
14
  sessionId;
15
- daemonSpawnAttempted = false;
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
- constructor(socketPath, sessionId) {
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 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.
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
- if (this.daemonSpawnAttempted)
48
+ const now = Date.now();
49
+ if (now - this.lastSpawnAttemptMs < DaemonClient.SPAWN_COOLDOWN_MS)
35
50
  return;
36
- this.daemonSpawnAttempted = true;
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: binaryEnvFor(binary),
70
+ env,
51
71
  });
52
- const done = () => { resolve(); };
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
- // First failure: try to auto-start the daemon (one-shot).
82
- // Subsequent failures skip the spawn and just retry.
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
- if (!socketPath) {
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 running daemon matches workspace hash ${hash}. Run 'vexp setup' or 'vexp daemon-cmd start' in the project.`,
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
- const daemon = new DaemonClient(socketPath);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-mcp",
3
- "version": "2.0.20",
3
+ "version": "2.0.21",
4
4
  "description": "vexp MCP server — AI context tools for coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",