vexp-mcp 2.0.24 → 2.0.25
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 +91 -30
- package/dist/tools/index-status.js +37 -2
- package/dist/tools/run-pipeline.js +9 -3
- package/package.json +3 -6
package/dist/daemon-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
389
|
-
if (fs.existsSync(
|
|
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.
|
|
423
|
-
*
|
|
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
|
|
478
|
-
*
|
|
479
|
-
*
|
|
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
|
-
*
|
|
486
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
3
|
+
"version": "2.0.25",
|
|
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 --
|
|
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
|
}
|