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.
@@ -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 OUR version.
108
- // Field report (Nathan): a 2.3.0 http server stayed authoritative for
109
- // 9 days across extension upgrades because reuse never compared
110
- // versions. On mismatch (or a legacy record without one) we take over:
111
- // SIGTERM the old listener and spawn the current build.
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 === CLI_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({ pid, port, startedAt: Date.now(), owner, version: CLI_VERSION });
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
- writeRegistry(live);
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
+ }
@@ -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");