vexp-mcp 2.0.24 → 2.0.26

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.
@@ -51,7 +51,7 @@ export class DaemonClient {
51
51
  this.lastSpawnAttemptMs = now;
52
52
  if (process.env.VEXP_NO_AUTOSTART === "1")
53
53
  return;
54
- const workspaceRoot = this.spawnWorkspaceRoot ?? process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
54
+ const workspaceRoot = resolveWorkspaceRoot(this.spawnWorkspaceRoot);
55
55
  if (!fs.existsSync(path.join(workspaceRoot, ".vexp", "manifest.json")))
56
56
  return;
57
57
  const binary = resolveVexpCoreBinary();
@@ -84,7 +84,7 @@ export class DaemonClient {
84
84
  }
85
85
  /** Tail of the daemon log files, for actionable connection errors. */
86
86
  daemonLogTail(maxLines = 12) {
87
- const workspaceRoot = this.spawnWorkspaceRoot ?? process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
87
+ const workspaceRoot = resolveWorkspaceRoot(this.spawnWorkspaceRoot);
88
88
  for (const name of ["daemon.log", "vexp.log"]) {
89
89
  try {
90
90
  const p = path.join(workspaceRoot, ".vexp", name);
@@ -383,16 +383,31 @@ export function fnvHash(input) {
383
383
  */
384
384
  function discoverWorkspaceRoot() {
385
385
  const cwd = process.cwd();
386
+ // Pass 1: nearest ancestor with an INITIALIZED .vexp/ (manifest.json or
387
+ // index.db) — a real indexed workspace, not a stray/empty .vexp/ dir.
388
+ // MUST stay in lockstep with vexp-core/src/utils.rs::discover_workspace_root.
386
389
  let current = cwd;
387
390
  while (true) {
388
- const vexpDir = path.join(current, ".vexp");
389
- if (fs.existsSync(vexpDir))
391
+ const vexp = path.join(current, ".vexp");
392
+ if (fs.existsSync(path.join(vexp, "manifest.json")) || fs.existsSync(path.join(vexp, "index.db"))) {
390
393
  return current;
394
+ }
391
395
  const parent = path.dirname(current);
392
396
  if (parent === current)
393
397
  break;
394
398
  current = parent;
395
399
  }
400
+ // Pass 2: nearest ancestor with a bare .vexp/ (workspace mid-first-index).
401
+ current = cwd;
402
+ while (true) {
403
+ if (fs.existsSync(path.join(current, ".vexp")))
404
+ return current;
405
+ const parent = path.dirname(current);
406
+ if (parent === current)
407
+ break;
408
+ current = parent;
409
+ }
410
+ // Pass 3: .git/ fallback.
396
411
  current = cwd;
397
412
  while (true) {
398
413
  const gitDir = path.join(current, ".git");
@@ -405,6 +420,25 @@ function discoverWorkspaceRoot() {
405
420
  }
406
421
  return cwd;
407
422
  }
423
+ /**
424
+ * Resolve the workspace root for daemon spawn / socket targeting.
425
+ *
426
+ * Precedence:
427
+ * 1. `explicit` — an HTTP per-workspace client's pinned root
428
+ * 2. `VEXP_WORKSPACE` env — VS Code / hand-config pin
429
+ * 3. `CLAUDE_PROJECT_DIR` env — Claude Code ALWAYS sets this to the launching
430
+ * session's project root. This is the reliable per-session signal that lets
431
+ * parallel Claude Code sessions each target their OWN project even though the
432
+ * user-scope ~/.claude.json MCP entry is shared (and intentionally no longer
433
+ * pins VEXP_WORKSPACE — see agent-config.ts:configureClaudeCodeGlobal).
434
+ * 4. cwd walk-up ({@link discoverWorkspaceRoot})
435
+ */
436
+ export function resolveWorkspaceRoot(explicit) {
437
+ return (explicit ??
438
+ process.env["VEXP_WORKSPACE"] ??
439
+ process.env["CLAUDE_PROJECT_DIR"] ??
440
+ discoverWorkspaceRoot());
441
+ }
408
442
  /**
409
443
  * Read `~/.vexp/daemons.json` and pick the best entry for the given workspace.
410
444
  *
@@ -419,8 +453,14 @@ function discoverWorkspaceRoot() {
419
453
  * 1. Exact workspace match
420
454
  * 2. Longest workspace that is a prefix of `workspaceHint` (handles
421
455
  * submodule / nested-directory cwd pointing at a parent daemon)
422
- * 3. If registry has exactly one entry, use it
423
- * 4. Otherwise `null` (caller should error clearly)
456
+ * 3. Otherwise `null` (caller surfaces a clear "no daemon for this workspace"
457
+ * error and/or respawns the right one).
458
+ *
459
+ * Deliberately NO "if exactly one entry, use it" fallback: in a multi-repo /
460
+ * multi-agent setup that silently routes a child meant for repo-A to the only
461
+ * other registered daemon (repo-B) → the "Repos indexed: 1 / wrong-repo" drift.
462
+ * A non-match returns null so resolution falls back to the workspace-local socket
463
+ * / spawn path, never to an unrelated repo.
424
464
  */
425
465
  function pickFromDaemonRegistry(workspaceHint) {
426
466
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
@@ -469,53 +509,74 @@ function pickFromDaemonRegistry(workspaceHint) {
469
509
  }
470
510
  if (bestSock)
471
511
  return bestSock;
472
- if (entries.length === 1)
473
- return entries[0][1];
474
512
  return null;
475
513
  }
476
514
  /**
477
- * Resolve the daemon socket path / named pipe, cross-platform, using:
478
- * 1. `VEXP_SOCKET` env explicit override (highest precedence)
479
- * 2. Workspace-local socket if it exists (`<workspaceRoot>/.vexp/daemon.sock`)
480
- * 3. Global daemon registry `~/.vexp/daemons.json` (written by the Rust
481
- * daemon at startup on all platforms)
482
- * 4. Platform-specific fallback (Windows hash pipe, otherwise `null` →
483
- * caller surfaces a clear "no daemon" error)
515
+ * Resolve the daemon socket/pipe AND record which signal decided the target.
516
+ * Single source of truth for `resolveDaemonSocket()` (which discards the
517
+ * metadata) and the targeting diagnostics.
484
518
  *
485
- * Does NOT fall back to `~/.vexp/daemon.sock` — the daemon never writes
486
- * there; that path is a dead-end that masks the real error.
519
+ * Precedence (matches {@link resolveWorkspaceRoot} + the socket lookup that
520
+ * `resolveDaemonSocket` historically used — `.socket` is byte-for-byte unchanged):
521
+ * 1. `VEXP_SOCKET` env — explicit socket override (highest precedence)
522
+ * 2. workspace root (VEXP_WORKSPACE > CLAUDE_PROJECT_DIR > cwd walk-up), then:
523
+ * a. workspace-local socket/pipe (`<root>/.vexp/daemon.{sock,pipe}`)
524
+ * b. global daemon registry `~/.vexp/daemons.json`
525
+ * c. Windows hash-pipe fallback, otherwise `null`
526
+ *
527
+ * Does NOT fall back to `~/.vexp/daemon.sock` — the daemon never writes there.
487
528
  */
488
- export function resolveDaemonSocket() {
529
+ export function resolveTargeting() {
530
+ // Which signal gives us the workspace root? (Reported even when VEXP_SOCKET
531
+ // overrides the socket, so the diagnostics still show the intended project.)
532
+ let source;
533
+ let workspaceRoot;
534
+ if (process.env["VEXP_WORKSPACE"]) {
535
+ workspaceRoot = process.env["VEXP_WORKSPACE"];
536
+ source = "VEXP_WORKSPACE";
537
+ }
538
+ else if (process.env["CLAUDE_PROJECT_DIR"]) {
539
+ workspaceRoot = process.env["CLAUDE_PROJECT_DIR"];
540
+ source = "CLAUDE_PROJECT_DIR";
541
+ }
542
+ else {
543
+ workspaceRoot = discoverWorkspaceRoot();
544
+ source = "cwd-discovery";
545
+ }
489
546
  const explicit = process.env["VEXP_SOCKET"];
490
- if (explicit)
491
- return explicit;
492
- const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
547
+ if (explicit) {
548
+ return { socket: explicit, source, socketOrigin: "explicit", workspaceRoot };
549
+ }
493
550
  if (process.platform === "win32") {
494
551
  // Workspace-specific pipe name file written by the daemon
495
552
  const pipeFile = path.join(workspaceRoot, ".vexp", "daemon.pipe");
496
553
  try {
497
554
  const pipeName = fs.readFileSync(pipeFile, "utf-8").trim();
498
555
  if (pipeName)
499
- return pipeName;
556
+ return { socket: pipeName, source, socketOrigin: "workspace-local", workspaceRoot };
500
557
  }
501
558
  catch { /* missing */ }
502
559
  // Registry
503
560
  const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
504
561
  if (fromRegistry)
505
- return fromRegistry;
562
+ return { socket: fromRegistry, source, socketOrigin: "registry", workspaceRoot };
506
563
  // Hash-based last-ditch (daemon should always register, so this is rare)
507
564
  const hash = fnvHash(workspaceRoot.toLowerCase());
508
- return `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`;
565
+ return { socket: `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`, source, socketOrigin: "hash-fallback", workspaceRoot };
509
566
  }
510
567
  // Unix: prefer workspace-local if the socket file actually exists
511
568
  const cwdSocket = path.join(workspaceRoot, ".vexp", "daemon.sock");
512
569
  if (fs.existsSync(cwdSocket))
513
- return cwdSocket;
514
- // Registry (Linux + macOS) — the piece that was missing
570
+ return { socket: cwdSocket, source, socketOrigin: "workspace-local", workspaceRoot };
571
+ // Registry (Linux + macOS)
515
572
  const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
516
573
  if (fromRegistry)
517
- return fromRegistry;
518
- return null;
574
+ return { socket: fromRegistry, source, socketOrigin: "registry", workspaceRoot };
575
+ return { socket: null, source, socketOrigin: "none", workspaceRoot };
576
+ }
577
+ /** Resolve the daemon socket path / named pipe (metadata discarded). */
578
+ export function resolveDaemonSocket() {
579
+ return resolveTargeting().socket;
519
580
  }
520
581
  /** Back-compat wrapper: some callers expect a string (old behavior).
521
582
  * Returns the resolved path or the nominal workspace path so the caller's
@@ -524,7 +585,7 @@ function getDefaultSocketPath() {
524
585
  const resolved = resolveDaemonSocket();
525
586
  if (resolved)
526
587
  return resolved;
527
- const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
588
+ const workspaceRoot = resolveWorkspaceRoot();
528
589
  return path.join(workspaceRoot, ".vexp", "daemon.sock");
529
590
  }
530
591
  export { discoverWorkspaceRoot };
@@ -602,7 +663,7 @@ function socketPathForWorkspace(ws) {
602
663
  /** Human-friendly hint printed when a connection attempt fails — enumerates
603
664
  * what we tried to make the failure actionable instead of misleading. */
604
665
  function daemonRunHint() {
605
- const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
666
+ const workspaceRoot = resolveWorkspaceRoot();
606
667
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
607
668
  const candidates = [];
608
669
  if (process.env["VEXP_SOCKET"])
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { resolveTargeting } from "../daemon-client.js";
2
3
  export const IndexStatusSchema = z.object({});
3
4
  export const INDEX_STATUS_DEFINITION = {
4
5
  name: "index_status",
@@ -13,10 +14,18 @@ export const INDEX_STATUS_DEFINITION = {
13
14
  };
14
15
  export async function handleIndexStatus(params, daemon) {
15
16
  IndexStatusSchema.parse(params);
17
+ // Client-side: how THIS MCP process resolved its target (before talking to any
18
+ // daemon). Compared against the daemon's self-report below to surface mis-targets.
19
+ const targeting = resolveTargeting();
16
20
  const result = await daemon.call("index_status", {});
17
- return formatStatusAsText(result);
21
+ return formatStatusAsText(result, targeting);
18
22
  }
19
- function formatStatusAsText(status) {
23
+ /** Normalize a workspace path for a tolerant equality check (case-insensitive,
24
+ * trailing separators stripped) — just for the mismatch heuristic. */
25
+ function normRoot(p) {
26
+ return p.replace(/[\\/]+$/, "").toLowerCase();
27
+ }
28
+ function formatStatusAsText(status, targeting) {
20
29
  const lines = [];
21
30
  const statusEmoji = {
22
31
  healthy: "✓",
@@ -27,6 +36,32 @@ function formatStatusAsText(status) {
27
36
  lines.push(`# vexp Index Status`);
28
37
  lines.push(`> ${statusEmoji} ${status.status.toUpperCase()} | v${status.daemon_version} | uptime ${formatUptime(status.daemon_uptime_s)}`);
29
38
  lines.push("");
39
+ // Targeting — which workspace THIS MCP process resolved to (client) vs which
40
+ // workspace the daemon it reached actually serves (daemon). A mismatch means this
41
+ // session is talking to the wrong project's daemon (the parallel-session bug).
42
+ if (targeting || status.workspace_root) {
43
+ const clientRoot = targeting?.workspaceRoot;
44
+ const daemonRoot = status.workspace_root;
45
+ lines.push("## Targeting");
46
+ if (targeting) {
47
+ lines.push(`- This MCP resolved to: \`${clientRoot}\` (via ${targeting.source})`);
48
+ if (targeting.socket)
49
+ lines.push(`- Socket/pipe: \`${targeting.socket}\` (${targeting.socketOrigin})`);
50
+ }
51
+ if (daemonRoot) {
52
+ const repos = status.served_aliases?.length ? ` — repos: ${status.served_aliases.join(", ")}` : "";
53
+ lines.push(`- Daemon serving: \`${daemonRoot}\`${repos}`);
54
+ }
55
+ if (clientRoot && daemonRoot && normRoot(clientRoot) !== normRoot(daemonRoot)) {
56
+ lines.push(`- ⚠️ MISMATCH: this session resolved a different workspace than the daemon it reached. ` +
57
+ `Likely a parallel-session mis-target — set VEXP_WORKSPACE for this session, or launch \`claude\` from \`${clientRoot}\`.`);
58
+ }
59
+ const others = status.other_daemons ?? [];
60
+ lines.push(others.length > 0
61
+ ? `- Other live daemons: ${others.map((d) => `\`${d.root}\``).join(", ")}`
62
+ : `- Other live daemons: none`);
63
+ lines.push("");
64
+ }
30
65
  if (status.status === "error" && status.error) {
31
66
  lines.push(`**Error:** ${status.error}`);
32
67
  lines.push("");
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { resolveTargeting } from "../daemon-client.js";
2
3
  import { coerceArray, coerceBool, coerceNumber } from "./coerce.js";
3
4
  export const RunPipelineSchema = z.object({
4
5
  task: z.string().describe("Description of your task"),
@@ -133,8 +134,13 @@ export const RUN_PIPELINE_DEFINITION = {
133
134
  };
134
135
  export async function handleRunPipeline(params, daemon) {
135
136
  const validated = RunPipelineSchema.parse(params);
137
+ // One-line target banner so the user can SEE which workspace this session
138
+ // resolved to (the parallel-session mis-target is otherwise invisible). Computed
139
+ // client-side from env/cwd — no extra daemon round-trip.
140
+ const targeting = resolveTargeting();
136
141
  const result = (await daemon.call("run_pipeline", validated));
137
- // The Rust engine already produces formatted text output.
138
- // We return it directly no additional formatting needed.
139
- return result.output;
142
+ const scoped = validated.repos?.length ? `, repos: ${validated.repos.join(", ")}` : "";
143
+ const banner = `> vexp target: ${targeting.workspaceRoot} (via ${targeting.source}${scoped})`;
144
+ // The Rust engine already produces formatted text output; we only prepend the banner.
145
+ return `${banner}\n${result.output}`;
140
146
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "vexp-mcp",
3
- "version": "2.0.24",
3
+ "version": "2.0.26",
4
4
  "description": "vexp MCP server — AI context tools for coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
9
  "dev": "tsx src/index.ts",
10
- "test": "node --experimental-vm-modules node_modules/.bin/jest",
10
+ "test": "node --import tsx --test test/*.test.ts",
11
11
  "clean": "rm -rf dist",
12
12
  "clean:artifacts": "node -e \"const fs=require('fs'),p=require('path');function w(d){if(!fs.existsSync(d))return;for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=p.join(d,e.name);if(e.isDirectory())w(f);else if(/\\.(map|d\\.ts|d\\.ts\\.map)$/.test(e.name))fs.unlinkSync(f);}}w('dist');\"",
13
13
  "prepublishOnly": "npm run build && npm run clean:artifacts"
@@ -30,9 +30,6 @@
30
30
  "@types/node": "^20.11.0",
31
31
  "@types/uuid": "^9.0.7",
32
32
  "typescript": "^5.4.0",
33
- "tsx": "^4.7.0",
34
- "jest": "^29.7.0",
35
- "@jest/globals": "^29.7.0",
36
- "ts-jest": "^29.1.2"
33
+ "tsx": "^4.7.0"
37
34
  }
38
35
  }