vexp-cli 3.1.1 → 3.1.3
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/agent-config.js +5 -1
- package/dist/cli.js +45 -5
- package/dist/doctor.js +323 -8
- package/dist/mcp-supervisor.js +103 -8
- package/dist/serve.js +40 -1
- package/dist/slow-mount.js +81 -0
- package/dist/socket-path.js +24 -0
- package/mcp/mcp-server.cjs +37 -35
- package/package.json +6 -6
package/dist/mcp-supervisor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CLI_VERSION } from "./version.js";
|
|
|
3
3
|
import * as net from "net";
|
|
4
4
|
import * as os from "os";
|
|
5
5
|
import * as path from "path";
|
|
6
|
-
import { spawn } from "child_process";
|
|
6
|
+
import { spawn, spawnSync } from "child_process";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { getMcpServerPath } from "./binary.js";
|
|
9
9
|
const DEFAULT_PORT = 7821;
|
|
@@ -33,6 +33,8 @@ export function readPidRecord() {
|
|
|
33
33
|
// pass (measured 60.7s replacement period). The reuse fast path
|
|
34
34
|
// is only as good as the record it reads.
|
|
35
35
|
version: typeof rec.version === "string" ? rec.version : undefined,
|
|
36
|
+
spawnedBy: typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined,
|
|
37
|
+
spawnedByPid: typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -97,6 +99,71 @@ export function resolveOrGenerateMcpToken() {
|
|
|
97
99
|
* The MCP server is deliberately workspace-agnostic: clients disambiguate
|
|
98
100
|
* via URL path /ws/<workspaceHash>/mcp. No VEXP_WORKSPACE env is passed.
|
|
99
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* Is the running server's version strictly older than ours?
|
|
104
|
+
*
|
|
105
|
+
* A record with no version predates version tracking (< 2.4) and counts as
|
|
106
|
+
* older. An unparseable version counts as older too: replacing it once is
|
|
107
|
+
* recoverable, whereas deferring to it forever is what the field report
|
|
108
|
+
* showed. A NEWER server is left alone — this process is the stale one.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* The version of the MCP bundle at `mcpPath`, read from the package.json that
|
|
112
|
+
* ships it — the build a client will actually be served, as opposed to the
|
|
113
|
+
* version of whatever process happens to be spawning it.
|
|
114
|
+
*/
|
|
115
|
+
/**
|
|
116
|
+
* The parent of a running process, or undefined.
|
|
117
|
+
*
|
|
118
|
+
* The supervisor that spawned the MCP server is its parent, and that is true
|
|
119
|
+
* however old the build that wrote ~/.vexp/mcp.pid — which is what makes it
|
|
120
|
+
* the right evidence for a record written before vexp recorded the spawner.
|
|
121
|
+
* Best-effort by design: an unreadable parent must degrade to "cannot tell",
|
|
122
|
+
* never to silence.
|
|
123
|
+
*/
|
|
124
|
+
export function parentPid(pid) {
|
|
125
|
+
try {
|
|
126
|
+
const r = process.platform === "win32"
|
|
127
|
+
? spawnSync("powershell", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`], { encoding: "utf-8", timeout: 4000 })
|
|
128
|
+
: spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf-8", timeout: 4000 });
|
|
129
|
+
const n = Number.parseInt(String(r.stdout ?? "").trim(), 10);
|
|
130
|
+
// pid 1 is init adopting an orphan: a real parent, but never OUR
|
|
131
|
+
// supervisor, and naming it would invite someone to kill init.
|
|
132
|
+
return Number.isFinite(n) && n > 1 ? n : undefined;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function mcpBundleVersion(mcpPath) {
|
|
139
|
+
const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(fs.readFileSync(pkg, "utf-8"));
|
|
142
|
+
if (typeof parsed.version === "string")
|
|
143
|
+
return parsed.version;
|
|
144
|
+
}
|
|
145
|
+
catch { /* not readable: fall back to the caller's own version */ }
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
export function isOlderVersion(recorded, mine) {
|
|
149
|
+
if (!recorded)
|
|
150
|
+
return true;
|
|
151
|
+
if (recorded === mine)
|
|
152
|
+
return false;
|
|
153
|
+
const parts = (v) => {
|
|
154
|
+
const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
|
|
155
|
+
return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
|
|
156
|
+
};
|
|
157
|
+
const a = parts(recorded);
|
|
158
|
+
const b = parts(mine);
|
|
159
|
+
if (!a || !b)
|
|
160
|
+
return true;
|
|
161
|
+
for (let i = 0; i < 3; i++) {
|
|
162
|
+
if (a[i] !== b[i])
|
|
163
|
+
return a[i] < b[i];
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
100
167
|
export async function ensureMcpHttpServer(opts = {}) {
|
|
101
168
|
const port = opts.port ?? DEFAULT_PORT;
|
|
102
169
|
const owner = opts.owner ?? "unknown";
|
|
@@ -104,17 +171,37 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
104
171
|
const mcpPath = getMcpServerPath();
|
|
105
172
|
if (!mcpPath)
|
|
106
173
|
return null;
|
|
107
|
-
// Fast path: already alive and tracked - but only if it is
|
|
108
|
-
// Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
109
|
-
// 9 days across extension upgrades because reuse never compared
|
|
110
|
-
//
|
|
111
|
-
//
|
|
174
|
+
// Fast path: already alive and tracked - but only if it is not OLDER than
|
|
175
|
+
// us. Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
176
|
+
// 9 days across extension upgrades because reuse never compared versions.
|
|
177
|
+
// On an older server we take over: SIGTERM the old listener and spawn the
|
|
178
|
+
// current build.
|
|
179
|
+
//
|
|
180
|
+
// The comparison is ORDERED, not an equality check. Equality made takeover
|
|
181
|
+
// symmetric, so two installs of different versions each saw the other as
|
|
182
|
+
// "not mine" and reclaimed the port from it — and `vexp serve` re-runs this
|
|
183
|
+
// every 60s, so a supervisor left behind by an older install resurrected
|
|
184
|
+
// its own build indefinitely. A tester watched a v3.0.1 server come back
|
|
185
|
+
// after being replaced by v3.1.1, with a fresh pid (tier-4 field report,
|
|
186
|
+
// 2026-09-05). An older build now stands down and reuses what is there.
|
|
112
187
|
{
|
|
113
188
|
const existing = readPidRecord();
|
|
114
189
|
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
115
|
-
if (existing.version
|
|
190
|
+
if (!isOlderVersion(existing.version, CLI_VERSION)) {
|
|
116
191
|
return { pid: existing.pid, port, started: false };
|
|
117
192
|
}
|
|
193
|
+
// Name the process that will undo this in sixty seconds, HERE, where the
|
|
194
|
+
// takeover happens — a user who never runs doctor otherwise watches the
|
|
195
|
+
// port revert with nothing to read. The supervisor is the parent of the
|
|
196
|
+
// server we are about to end; the recorded pid, when a recent build
|
|
197
|
+
// wrote it, says the same thing without a process lookup.
|
|
198
|
+
const boss = existing.spawnedByPid ?? parentPid(existing.pid);
|
|
199
|
+
if (boss !== undefined && isPidAlive(boss)) {
|
|
200
|
+
console.error(` ⚠ replacing the MCP server on :${port} (was v${existing.version ?? "pre-2.4"}). ` +
|
|
201
|
+
`It was started by pid ${boss}, a supervisor from an older install.\n` +
|
|
202
|
+
` If :${port} reverts, end that process FIRST — before restarting any daemon: its ` +
|
|
203
|
+
`health loop deletes daemon registrations as fast as they are written.`);
|
|
204
|
+
}
|
|
118
205
|
try {
|
|
119
206
|
process.kill(existing.pid, "SIGTERM");
|
|
120
207
|
}
|
|
@@ -184,7 +271,15 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
184
271
|
const pid = child.pid;
|
|
185
272
|
if (!pid)
|
|
186
273
|
return null;
|
|
187
|
-
writePidRecord({
|
|
274
|
+
writePidRecord({
|
|
275
|
+
pid,
|
|
276
|
+
port,
|
|
277
|
+
startedAt: Date.now(),
|
|
278
|
+
owner,
|
|
279
|
+
version: mcpBundleVersion(mcpPath) ?? CLI_VERSION,
|
|
280
|
+
spawnedBy: CLI_VERSION,
|
|
281
|
+
spawnedByPid: process.pid,
|
|
282
|
+
});
|
|
188
283
|
// Wait for the child to actually bind the port BEFORE releasing the
|
|
189
284
|
// lock. Without this, a second caller arriving right after release
|
|
190
285
|
// sees pidfile+live-pid but port-not-yet-bound → falsely concludes
|
package/dist/serve.js
CHANGED
|
@@ -207,7 +207,46 @@ async function resurrectAll() {
|
|
|
207
207
|
if (resurrected.get(sock))
|
|
208
208
|
live[ws] = sock;
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// A row this supervisor did not resurrect is not thereby dead.
|
|
211
|
+
//
|
|
212
|
+
// `live` used to become the WHOLE new registry, so every row the plan
|
|
213
|
+
// skipped — another supervisor's daemon, a linked worktree, a workspace
|
|
214
|
+
// whose manifest had moved — was deleted while its daemon was still
|
|
215
|
+
// running and answering on its socket. The health loop re-runs this every
|
|
216
|
+
// 60s, so the deletion was permanent and self-renewing: on a tester's
|
|
217
|
+
// machine two daemons that had been serving a pre-upgrade build for five
|
|
218
|
+
// days disappeared from `vexp daemons`, from `index_status`'s "other live
|
|
219
|
+
// daemons" and from the concurrency fence the moment a supervisor
|
|
220
|
+
// restarted, leaving a three-daemon box reporting a clean single-workspace
|
|
221
|
+
// install (field report, 2026-09-07).
|
|
222
|
+
//
|
|
223
|
+
// Liveness decides what stays in the registry, never ownership. The fence
|
|
224
|
+
// counts these rows, so dropping a live daemon also made the cap evadable
|
|
225
|
+
// by restarting a supervisor.
|
|
226
|
+
writeRegistry(await survivingRegistryRows(reg, live, isSocketAlive, appendLog));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The rows that stay in `~/.vexp/daemons.json` after a resurrection pass:
|
|
230
|
+
* everything this supervisor brought up, PLUS every other row whose daemon is
|
|
231
|
+
* still answering. Only a row that is neither is dropped.
|
|
232
|
+
*
|
|
233
|
+
* Separated out because the rule is the whole bug: a supervisor may only
|
|
234
|
+
* report what it could not find, never delete what it does not own.
|
|
235
|
+
*/
|
|
236
|
+
export async function survivingRegistryRows(before, resurrected, alive, log = () => { }) {
|
|
237
|
+
const canonical = (ws) => (process.platform === "win32" ? ws.toLowerCase() : ws);
|
|
238
|
+
const out = { ...resurrected };
|
|
239
|
+
const kept = new Set(Object.keys(out).map(canonical));
|
|
240
|
+
for (const [ws, sock] of Object.entries(before)) {
|
|
241
|
+
if (kept.has(canonical(ws)))
|
|
242
|
+
continue;
|
|
243
|
+
if (await alive(sock)) {
|
|
244
|
+
out[ws] = sock;
|
|
245
|
+
kept.add(canonical(ws));
|
|
246
|
+
log(`registry keep: ${ws} (not ours to resurrect, but its daemon is alive)`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
211
250
|
}
|
|
212
251
|
async function startMcp() {
|
|
213
252
|
const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slow-filesystem detection for doctor.
|
|
3
|
+
*
|
|
4
|
+
* CLI and VS Code twins: packages/vexp-cli/src/slow-mount.ts and
|
|
5
|
+
* packages/vexp-vscode/src/slow-mount.ts must stay byte-identical (a
|
|
6
|
+
* lockstep test compares them). Pure functions take the /proc/mounts text
|
|
7
|
+
* so they are testable anywhere; only `slowMountNotice` touches the OS.
|
|
8
|
+
*
|
|
9
|
+
* Field report (2026-09-09): a 229-file repo on /app inside Docker Desktop,
|
|
10
|
+
* a Windows drive over 9p. verify_done at 41-170 s, 19 slow-statement
|
|
11
|
+
* warnings from SQLite (a bare INSERT at 1.25 s), 49 slow connection
|
|
12
|
+
* acquires. Nothing in vexp was wrong on that machine except that the whole
|
|
13
|
+
* workspace, index included, sat behind an OS boundary. Doctor said
|
|
14
|
+
* everything was [OK]. It now says this.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
/** Filesystem types where every stat, read and git walk crosses an OS boundary. */
|
|
18
|
+
export const SLOW_FS_TYPES = {
|
|
19
|
+
"9p": "a host drive mounted over 9p (WSL2 /mnt/*, Docker Desktop bind mount)",
|
|
20
|
+
drvfs: "a Windows drive mounted in WSL (drvfs)",
|
|
21
|
+
"fuse.grpcfuse": "a macOS host folder bind-mounted into Docker Desktop (grpcfuse)",
|
|
22
|
+
virtiofs: "a host folder shared into a VM (virtiofs)",
|
|
23
|
+
vboxsf: "a VirtualBox shared folder",
|
|
24
|
+
prl_fs: "a Parallels shared folder",
|
|
25
|
+
cifs: "a network share (CIFS/SMB)",
|
|
26
|
+
smb3: "a network share (SMB3)",
|
|
27
|
+
nfs: "a network share (NFS)",
|
|
28
|
+
nfs4: "a network share (NFS)",
|
|
29
|
+
"fuse.sshfs": "a remote folder over sshfs",
|
|
30
|
+
};
|
|
31
|
+
/** The mount that holds `root`: the longest mount point that is a prefix of it. */
|
|
32
|
+
export function mountFor(root, mountsText) {
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const line of mountsText.split("\n")) {
|
|
35
|
+
const parts = line.split(" ");
|
|
36
|
+
if (parts.length < 3)
|
|
37
|
+
continue;
|
|
38
|
+
// /proc/mounts escapes spaces in mount points as \040.
|
|
39
|
+
const mountPoint = parts[1].replace(/\\040/g, " ");
|
|
40
|
+
const fsType = parts[2];
|
|
41
|
+
const prefix = mountPoint.endsWith("/") ? mountPoint : mountPoint + "/";
|
|
42
|
+
if (root === mountPoint || root.startsWith(prefix)) {
|
|
43
|
+
if (!best || mountPoint.length > best.mountPoint.length)
|
|
44
|
+
best = { mountPoint, fsType };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
/** The WARN text when the workspace sits on a slow mount; null when it does not. */
|
|
50
|
+
export function slowMountVerdict(root, mountsText) {
|
|
51
|
+
const m = mountFor(root, mountsText);
|
|
52
|
+
if (!m)
|
|
53
|
+
return null;
|
|
54
|
+
const what = SLOW_FS_TYPES[m.fsType];
|
|
55
|
+
if (!what)
|
|
56
|
+
return null;
|
|
57
|
+
return (`workspace is on ${what}: ${m.fsType} at ${m.mountPoint}. Every stat, read and git walk ` +
|
|
58
|
+
"crosses an OS boundary, 10-50x slower than a native disk; indexing, verify_done and the " +
|
|
59
|
+
"SQLite index in .vexp/ all pay it on every call. Keep the clone on the Linux filesystem " +
|
|
60
|
+
"(WSL: under ~, not /mnt/c; Docker: a named volume, not a bind mount from the host).");
|
|
61
|
+
}
|
|
62
|
+
/** Linux only: reads /proc/mounts. Any other platform, or an unreadable table, is silent. */
|
|
63
|
+
export function slowMountNotice(root) {
|
|
64
|
+
if (process.platform !== "linux")
|
|
65
|
+
return null;
|
|
66
|
+
let text;
|
|
67
|
+
try {
|
|
68
|
+
text = fs.readFileSync("/proc/mounts", "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
let real = root;
|
|
74
|
+
try {
|
|
75
|
+
real = fs.realpathSync(root);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* an unresolvable root is still matchable as given */
|
|
79
|
+
}
|
|
80
|
+
return slowMountVerdict(real, text);
|
|
81
|
+
}
|
package/dist/socket-path.js
CHANGED
|
@@ -27,6 +27,30 @@ export function socketPathFor(workspaceRoot) {
|
|
|
27
27
|
return candidate;
|
|
28
28
|
return `/tmp/vexp-${fnvHash(workspaceRoot).slice(0, 12)}.sock`;
|
|
29
29
|
}
|
|
30
|
+
/** Longest `<root>/.vexp/daemon.sock` vexp binds in place; past it the socket lives in /tmp. */
|
|
31
|
+
export const UNIX_SOCKET_PATH_LIMIT = 100;
|
|
32
|
+
/**
|
|
33
|
+
* How close a workspace is to the relocation above — the number doctor
|
|
34
|
+
* prints. A tester measured his Claude Code worktrees at 78-89 characters
|
|
35
|
+
* (auto-named `adjective-name-hash` directories under `.claude/worktrees/`)
|
|
36
|
+
* and found nothing that would have announced the move when a longer name
|
|
37
|
+
* crossed the line (field report, 2026-09-10). On Windows there is no path
|
|
38
|
+
* to measure: the pipe name is a hash.
|
|
39
|
+
*/
|
|
40
|
+
export function socketPathMargin(workspaceRoot) {
|
|
41
|
+
const candidate = path.join(workspaceRoot, ".vexp", "daemon.sock");
|
|
42
|
+
if (process.platform === "win32") {
|
|
43
|
+
return { candidate, length: candidate.length, limit: UNIX_SOCKET_PATH_LIMIT, margin: Infinity, relocated: false };
|
|
44
|
+
}
|
|
45
|
+
const length = candidate.length;
|
|
46
|
+
return {
|
|
47
|
+
candidate,
|
|
48
|
+
length,
|
|
49
|
+
limit: UNIX_SOCKET_PATH_LIMIT,
|
|
50
|
+
margin: UNIX_SOCKET_PATH_LIMIT - length,
|
|
51
|
+
relocated: length > UNIX_SOCKET_PATH_LIMIT,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
30
54
|
/** FNV-1a 64-bit, hex, unpadded — mirrors md5_hash() in vexp-core/src/utils.rs. */
|
|
31
55
|
export function fnvHash(input) {
|
|
32
56
|
let hash = BigInt("0xcbf29ce484222325");
|