vexp-cli 3.1.0 → 3.1.2

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/doctor.js CHANGED
@@ -5,7 +5,9 @@ import * as net from "net";
5
5
  import { spawnSync } from "child_process";
6
6
  import chalk from "chalk";
7
7
  import { socketPathFor } from "./socket-path.js";
8
- import { parseJsonc } from "./agent-config.js";
8
+ import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath } from "./agent-config.js";
9
+ import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
10
+ import { CLI_VERSION } from "./version.js";
9
11
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
10
12
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
11
13
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -24,7 +26,8 @@ function vexpHome() {
24
26
  function discoverWorkspaceRoot(start) {
25
27
  for (const test of [
26
28
  (d) => fs.existsSync(path.join(d, ".vexp", "manifest.json")) || fs.existsSync(path.join(d, ".vexp", "index.db")),
27
- (d) => fs.existsSync(path.join(d, ".vexp")),
29
+ // Bare .vexp — except home/VEXP_HOME, whose .vexp is vexp's state dir.
30
+ (d) => fs.existsSync(path.join(d, ".vexp")) && path.resolve(d) !== path.resolve(vexpHome()),
28
31
  (d) => fs.existsSync(path.join(d, ".git")),
29
32
  ]) {
30
33
  let cur = start;
@@ -204,7 +207,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
204
207
  * wherever it stands. Null when nothing was skipped for size, or when the
205
208
  * file predates 3.1 and has no skip keys.
206
209
  */
207
- export function coverageVerdict(cov) {
210
+ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
208
211
  if (!cov || typeof cov !== "object")
209
212
  return null;
210
213
  const c = cov;
@@ -222,8 +225,13 @@ export function coverageVerdict(cov) {
222
225
  level: WARN,
223
226
  message: `${oversized} file(s) over max_file_size_kb = ${cap} are NOT indexed — their symbols and callers are invisible to impact, search and run_pipeline:\n` +
224
227
  examples.map((e) => ` - ${e}`).join("\n") +
225
- (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
226
- `\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns.`,
228
+ (more > 0 ? `\n … +${more} more` : "") +
229
+ // The path, not the name. coverage.json lives at the WORKSPACE root;
230
+ // a tester on a monorepo looked for it beside the package he was
231
+ // working in, found nothing, and concluded the feature had not
232
+ // shipped (tier-4 field report, 2026-09-05).
233
+ `\n full list: ${coveragePath}` +
234
+ `\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns — excluded files stop being counted here at the next reindex ('vexp index').`,
227
235
  };
228
236
  }
229
237
  /**
@@ -266,8 +274,85 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
266
274
  }
267
275
  return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
268
276
  }
277
+ /**
278
+ * How doctor runs a Codex `UserPromptSubmit` hook line: the way Codex does.
279
+ * On Windows Codex hands `cmd.exe /C` the WHOLE line wrapped in one more pair
280
+ * of quotes, verbatim (codex-rs/hooks engine/command_runner: `raw_arg`), and
281
+ * cmd strips that outer pair — which is why the installer may quote the
282
+ * script path. Passing the line as a normal argument instead lets Node escape
283
+ * the inner quotes, and cmd then looks for a program literally named
284
+ * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
285
+ * failed doctor's probe and passed once the user removed the quotes).
286
+ */
287
+ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
288
+ if (platform === "win32") {
289
+ return { file: comspec || "cmd.exe", args: ["/C", `"${cmdLine}"`], windowsVerbatimArguments: true };
290
+ }
291
+ return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
292
+ }
293
+ function samePath(a, b) {
294
+ // The daemon registry lower-cases Windows roots; compare accordingly.
295
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
296
+ }
297
+ /**
298
+ * A connected repo of a multi-repo workspace has no daemon of its own: the
299
+ * parent's daemon serves it. Doctor run inside such a repo used to treat it as
300
+ * a standalone workspace — "daemon NOT reachable", "NOT registered" — while
301
+ * `vexp search` from the same directory answered fine (field report,
302
+ * 2026-09-01, nine repos). The link is `.vexp/parent_workspace.json` in the
303
+ * member; when that file is missing, any registered workspace whose
304
+ * workspace.json lists this directory is the parent.
305
+ */
306
+ export function resolveMemberParent(root, registryRoots) {
307
+ const link = resolveParentWorkspace(root);
308
+ if (link) {
309
+ const alias = listWorkspaceRepos(link.parentRoot).find((r) => samePath(r.resolvedPath, root))?.alias
310
+ ?? path.basename(root);
311
+ return { parentRoot: link.parentRoot, alias, via: ".vexp/parent_workspace.json" };
312
+ }
313
+ for (const reg of registryRoots) {
314
+ if (samePath(reg, root))
315
+ continue;
316
+ const hit = listWorkspaceRepos(reg).find((r) => !r.isPrimary && samePath(r.resolvedPath, root));
317
+ if (hit)
318
+ return { parentRoot: reg, alias: hit.alias, via: `${path.join(reg, ".vexp", "workspace.json")} (daemon registry)` };
319
+ }
320
+ return null;
321
+ }
322
+ /**
323
+ * Coverage verdicts for the workspace at `root` AND every repo connected to
324
+ * it, each tagged with its alias. Each repo's indexer writes its own
325
+ * `.vexp/coverage.json`; reading only the primary's hid three oversized files
326
+ * in a connected repo from a doctor run at the primary (same report).
327
+ */
328
+ export function workspaceCoverageFindings(root) {
329
+ const repos = listWorkspaceRepos(root);
330
+ const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
331
+ if (!targets.some((t) => t.primary))
332
+ targets.unshift({ alias: path.basename(root), dir: root, primary: true });
333
+ const out = [];
334
+ for (const t of targets) {
335
+ let cov;
336
+ try {
337
+ cov = JSON.parse(fs.readFileSync(path.join(t.dir, ".vexp", "coverage.json"), "utf-8"));
338
+ }
339
+ catch {
340
+ continue; /* never indexed, or an index older than 2.7 */
341
+ }
342
+ const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
343
+ if (v)
344
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
345
+ }
346
+ return out;
347
+ }
269
348
  export async function runDoctor() {
270
349
  const home = vexpHome();
350
+ const regPath = path.join(home, ".vexp", "daemons.json");
351
+ let registry = {};
352
+ try {
353
+ registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
354
+ }
355
+ catch { /* absent */ }
271
356
  let warns = 0;
272
357
  let fails = 0;
273
358
  const line = (status, msg) => {
@@ -280,8 +365,15 @@ export async function runDoctor() {
280
365
  console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
281
366
  // 1) Workspace resolution for the current directory.
282
367
  console.log(chalk.bold("Workspace targeting"));
283
- const ws = resolveWorkspace();
368
+ const resolved = resolveWorkspace();
369
+ const member = resolveMemberParent(resolved.root, Object.keys(registry));
370
+ const ws = member
371
+ ? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
372
+ : resolved;
284
373
  console.log(` cwd: ${process.cwd()}`);
374
+ if (member) {
375
+ line(OK, `this directory is repo '${member.alias}' of the multi-repo workspace at ${member.parentRoot} — served by that workspace's daemon; diagnostics below target it`);
376
+ }
285
377
  line(OK, `resolves to: ${ws.root} (via ${ws.source})`);
286
378
  // A *global* VEXP_WORKSPACE env var overrides the per-session signal
287
379
  // (CLAUDE_PROJECT_DIR) for EVERY agent session, pinning Claude *and* Codex to
@@ -307,9 +399,18 @@ export async function runDoctor() {
307
399
  if (live) {
308
400
  const st = await queryDaemon(sock, "index_status");
309
401
  if (st) {
402
+ // A daemon that is still loading its model answers "rule", which reads
403
+ // as a downgrade for the ~20s it takes to settle. The daemon already
404
+ // says which of the two it is; doctor was printing the label and
405
+ // dropping the reason (tier-4 field report, 2026-09-05).
406
+ const loading = st.compressor !== "llm"
407
+ && typeof st.llm_inactive_reason === "string"
408
+ && /still loading|finishing its startup/i.test(st.llm_inactive_reason);
310
409
  const comp = st.compressor === "llm"
311
410
  ? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
312
- : st.compressor ?? "unknown (older daemon)";
411
+ : loading
412
+ ? "loading (the model is still starting — this is not the rule compressor settling in)"
413
+ : st.compressor ?? "unknown (older daemon)";
313
414
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
314
415
  if (st.llm_configured_but_inactive === true) {
315
416
  // The daemon knows WHY, and doctor was throwing it away: it printed
@@ -388,21 +489,12 @@ export async function runDoctor() {
388
489
  // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
389
490
  // not from the daemon: the index that skipped the files may have been
390
491
  // built by a daemon that is no longer running.
391
- try {
392
- const cov = JSON.parse(fs.readFileSync(path.join(ws.root, ".vexp", "coverage.json"), "utf-8"));
393
- const v = coverageVerdict(cov);
394
- if (v)
395
- line(v.level, v.message);
396
- }
397
- catch { /* no coverage.json: never indexed here, or an index older than 2.7 */ }
492
+ // Every connected repo has its own coverage.json; each finding carries the
493
+ // repo alias so a run at the primary sees the whole workspace.
494
+ for (const f of workspaceCoverageFindings(ws.root))
495
+ line(f.level, f.message);
398
496
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
399
497
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
400
- const regPath = path.join(home, ".vexp", "daemons.json");
401
- let registry = {};
402
- try {
403
- registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
404
- }
405
- catch { /* absent */ }
406
498
  const entries = Object.entries(registry);
407
499
  if (entries.length === 0) {
408
500
  line(OK, "no registered daemons (empty/absent registry)");
@@ -540,6 +632,32 @@ export async function runDoctor() {
540
632
  console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
541
633
  }
542
634
  }
635
+ // 5a-bis) WSL: agents on the Windows side read Windows-side configs, which
636
+ // nothing running in WSL can see or write. Say so, and for Windsurf hand
637
+ // over the exact entry (field report, 2026-09-01: a user built the bridge by
638
+ // hand and found no documentation).
639
+ if (isWsl()) {
640
+ console.log(chalk.bold("\nWSL"));
641
+ const distro = process.env.WSL_DISTRO_NAME ?? "?";
642
+ line(OK, `running inside WSL (${distro}) — editors and apps on the Windows side read their own configs there; vexp writes only WSL-side files`);
643
+ const windsurfHere = fs.existsSync(path.join(ws.root, ".windsurf")) || fs.existsSync(path.join(ws.root, ".devin"));
644
+ if (windsurfHere) {
645
+ let bin = "vexp-core";
646
+ try {
647
+ const { getBinaryPath } = await import("./binary.js");
648
+ bin = getBinaryPath();
649
+ }
650
+ catch { /* note still useful without the exact binary path */ }
651
+ const bundle = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "mcp", "mcp-server.cjs");
652
+ line(WARN, `Windsurf/Devin Desktop on Windows will not read ${windsurfGlobalMcpPath()} (WSL home). Windows-side entry to paste:`);
653
+ for (const l of windsurfWslBridgeNote(ws.root, bin, fs.existsSync(bundle) ? bundle : undefined).split("\n")) {
654
+ console.log(chalk.dim(` ${l}`));
655
+ }
656
+ }
657
+ else {
658
+ console.log(chalk.dim(" Windows-side apps (Codex app, Claude Desktop, Windsurf) need a wsl.exe bridge entry: https://vexp.dev/docs#windows-wsl"));
659
+ }
660
+ }
543
661
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
544
662
  // just check presence. A shell-form command that word-splits on a project
545
663
  // path containing a space fails non-blocking on every call: the guard never
@@ -792,15 +910,18 @@ export async function runDoctor() {
792
910
  line(BAD, "the entry has no 'commandWindows' — Codex runs hooks through cmd.exe on Windows, which cannot run the bash script, so orientation never fires (unless Git Bash is on PATH). Re-run 'vexp setup' to write the batch twin.");
793
911
  }
794
912
  else {
795
- // Run it, through the same shell Codex would use: cmd.exe on
796
- // Windows (COMSPEC), sh elsewhere. The script bakes an absolute path
913
+ // Run it, through the same shell Codex would use — cmd.exe on
914
+ // Windows (COMSPEC) with the line wrapped the way Codex wraps it, sh
915
+ // elsewhere (codexHookSpawnSpec). The script bakes an absolute path
797
916
  // to the vexp binary and exits 0 when that path is not executable, so
798
917
  // a stale or wrong-profile path leaves NO trace anywhere: no
799
918
  // orientation, no error, forever. A Windows user found exactly that
800
919
  // by reading the generated file.
801
920
  const isWin = process.platform === "win32";
802
921
  const cmdLine = (isWin ? hook.commandWindows : hook.command);
803
- const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
922
+ const spec = codexHookSpawnSpec(cmdLine);
923
+ const r = spawnSync(spec.file, spec.args, {
924
+ windowsVerbatimArguments: spec.windowsVerbatimArguments,
804
925
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
805
926
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
806
927
  timeout: 10000,
@@ -897,11 +1018,54 @@ export async function runDoctor() {
897
1018
  }
898
1019
  }
899
1020
  }
1021
+ // A supervisor from an older install respawning the current bundle. It
1022
+ // keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
1023
+ // stop it: the old process runs the old comparison. The only fix is to end
1024
+ // it, so doctor has to name it — with its pid, which the record now
1025
+ // carries. A tester spent a night on a :7821 server that came back with a
1026
+ // new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
1027
+ // was not installed anywhere: the LABEL came from a `vexp serve` resident
1028
+ // since before the upgrade, holding its own version in memory
1029
+ // (field report, 2026-09-06).
1030
+ const staleSupervisorLine = (rec) => {
1031
+ const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
1032
+ if (!by || by === CLI_VERSION)
1033
+ return;
1034
+ const pid = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
1035
+ const stillUp = pid !== undefined && isAlive(pid);
1036
+ const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
1037
+ line(WARN, `that server was spawned by a ${who} running v${by}, not v${CLI_VERSION}` +
1038
+ (pid ? ` (pid ${pid}${stillUp ? ", still running" : ", already gone"})` : "") +
1039
+ `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
1040
+ // Ending it is the whole remediation: login autostart (or the next
1041
+ // `vexp` command) starts a fresh supervisor, which reads the current
1042
+ // package.json and serves the current build. The tester who did this
1043
+ // had a correct server one second later without running anything
1044
+ // else — and the `vexp setup` this line used to recommend blocks on
1045
+ // an interactive prompt, which for the reader of a doctor line is a
1046
+ // hang, not a fix (field report, 2026-09-07).
1047
+ (stillUp ? `\n end it — 'kill ${pid}' — a fresh supervisor starts on its own and serves the current build` : ""));
1048
+ };
900
1049
  // 6) HTTP MCP supervisor.
901
1050
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
902
1051
  try {
903
1052
  const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
904
- line(isAlive(rec.pid) ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${isAlive(rec.pid) ? "alive" : "DEAD (stale pid file)"}`);
1053
+ const alive = isAlive(rec.pid);
1054
+ line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
1055
+ if (alive && rec.version === CLI_VERSION)
1056
+ staleSupervisorLine(rec);
1057
+ // The supervisor is replaced on version mismatch only when a vexp command
1058
+ // next runs; until then every HTTP client (Codex http transport, a
1059
+ // `serverUrl` entry) talks to the OLD build — which advertises the old
1060
+ // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
1061
+ // saw two vexp servers with different tool counts and could not tell why.
1062
+ if (alive && rec.version && rec.version !== CLI_VERSION) {
1063
+ line(WARN, `the HTTP MCP server on :${rec.port} is v${rec.version} but this CLI is v${CLI_VERSION} — HTTP clients get the old build until it is replaced — run 'vexp setup' in the WORKSPACE ROOT (the directory holding .vexp/) and the current CLI takes it over`);
1064
+ staleSupervisorLine(rec);
1065
+ }
1066
+ else if (alive && !rec.version) {
1067
+ line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup' in the WORKSPACE ROOT so the current CLI replaces it and HTTP clients get v${CLI_VERSION}`);
1068
+ }
905
1069
  }
906
1070
  catch {
907
1071
  line(OK, "not running (no mcp.pid)");
@@ -916,4 +1080,8 @@ export async function runDoctor() {
916
1080
  console.log(chalk.dim(" Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs; restart Codex to drop a cached config."));
917
1081
  }
918
1082
  console.log("");
1083
+ // Scripts and CI read the exit code, not the colors: any FAIL exits 1.
1084
+ // Warnings alone stay 0 — advice, not breakage (field request, 2026-09-02).
1085
+ if (fails > 0)
1086
+ process.exitCode = 1;
919
1087
  }
@@ -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,48 @@ 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
+ export function mcpBundleVersion(mcpPath) {
116
+ const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
117
+ try {
118
+ const parsed = JSON.parse(fs.readFileSync(pkg, "utf-8"));
119
+ if (typeof parsed.version === "string")
120
+ return parsed.version;
121
+ }
122
+ catch { /* not readable: fall back to the caller's own version */ }
123
+ return undefined;
124
+ }
125
+ export function isOlderVersion(recorded, mine) {
126
+ if (!recorded)
127
+ return true;
128
+ if (recorded === mine)
129
+ return false;
130
+ const parts = (v) => {
131
+ const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
132
+ return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
133
+ };
134
+ const a = parts(recorded);
135
+ const b = parts(mine);
136
+ if (!a || !b)
137
+ return true;
138
+ for (let i = 0; i < 3; i++) {
139
+ if (a[i] !== b[i])
140
+ return a[i] < b[i];
141
+ }
142
+ return false;
143
+ }
100
144
  export async function ensureMcpHttpServer(opts = {}) {
101
145
  const port = opts.port ?? DEFAULT_PORT;
102
146
  const owner = opts.owner ?? "unknown";
@@ -104,15 +148,23 @@ export async function ensureMcpHttpServer(opts = {}) {
104
148
  const mcpPath = getMcpServerPath();
105
149
  if (!mcpPath)
106
150
  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.
151
+ // Fast path: already alive and tracked - but only if it is not OLDER than
152
+ // us. Field report (Nathan): a 2.3.0 http server stayed authoritative for
153
+ // 9 days across extension upgrades because reuse never compared versions.
154
+ // On an older server we take over: SIGTERM the old listener and spawn the
155
+ // current build.
156
+ //
157
+ // The comparison is ORDERED, not an equality check. Equality made takeover
158
+ // symmetric, so two installs of different versions each saw the other as
159
+ // "not mine" and reclaimed the port from it — and `vexp serve` re-runs this
160
+ // every 60s, so a supervisor left behind by an older install resurrected
161
+ // its own build indefinitely. A tester watched a v3.0.1 server come back
162
+ // after being replaced by v3.1.1, with a fresh pid (tier-4 field report,
163
+ // 2026-09-05). An older build now stands down and reuses what is there.
112
164
  {
113
165
  const existing = readPidRecord();
114
166
  if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
115
- if (existing.version === CLI_VERSION) {
167
+ if (!isOlderVersion(existing.version, CLI_VERSION)) {
116
168
  return { pid: existing.pid, port, started: false };
117
169
  }
118
170
  try {
@@ -184,7 +236,15 @@ export async function ensureMcpHttpServer(opts = {}) {
184
236
  const pid = child.pid;
185
237
  if (!pid)
186
238
  return null;
187
- writePidRecord({ pid, port, startedAt: Date.now(), owner, version: CLI_VERSION });
239
+ writePidRecord({
240
+ pid,
241
+ port,
242
+ startedAt: Date.now(),
243
+ owner,
244
+ version: mcpBundleVersion(mcpPath) ?? CLI_VERSION,
245
+ spawnedBy: CLI_VERSION,
246
+ spawnedByPid: process.pid,
247
+ });
188
248
  // Wait for the child to actually bind the port BEFORE releasing the
189
249
  // lock. Without this, a second caller arriving right after release
190
250
  // sees pidfile+live-pid but port-not-yet-bound → falsely concludes
package/dist/serve.js CHANGED
@@ -108,6 +108,33 @@ export function isOwnerEntry(ws, sock) {
108
108
  * cross-wired daemon serving the child's index on the parent's socket, which
109
109
  * then blocked every legitimate restart of the parent (it held the bind) and
110
110
  * never matched the parent's config. Routing rows must never be spawned. */
111
+ /** A linked git worktree: `.git` is a FILE whose `gitdir:` points into
112
+ * `<main>/.git/worktrees/`. Agent harnesses create one per parallel task
113
+ * (Claude Code under `.claude/worktrees/`, Cursor, plain `git worktree add`
114
+ * — the detection is the file's content, never a path convention). After a
115
+ * reboot the supervisor resurrected 15 of them alongside 2 canonical repos
116
+ * and took a 16-core host down: ~1,585% CPU, 2 GB/s of reads, hard reset
117
+ * (field incident, 2026-09-02, tier 3). Worktree daemons run on demand
118
+ * only; resurrection is for canonical workspaces. A submodule's gitdir
119
+ * points at `/.git/modules/` and is left alone. Mirrors vexp-core
120
+ * utils::git_project_root. */
121
+ export function isLinkedWorktree(ws) {
122
+ try {
123
+ const dotGit = path.join(ws, ".git");
124
+ if (fs.statSync(dotGit).isDirectory())
125
+ return false;
126
+ const gitdir = fs
127
+ .readFileSync(dotGit, "utf-8")
128
+ .split(/\r?\n/)
129
+ .find((l) => l.trim().startsWith("gitdir:"));
130
+ if (!gitdir)
131
+ return false;
132
+ return gitdir.replace(/\\/g, "/").includes("/.git/worktrees/");
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
111
138
  export function resurrectionPlan(reg) {
112
139
  const bySock = new Map();
113
140
  for (const [ws, sock] of Object.entries(reg)) {
@@ -156,17 +183,70 @@ async function resurrectAll() {
156
183
  appendLog(`registry prune: ${ws} (no manifest)`);
157
184
  continue;
158
185
  }
186
+ if (isLinkedWorktree(ws)) {
187
+ appendLog(`registry prune: ${ws} (linked git worktree — on-demand only, never resurrected)`);
188
+ continue;
189
+ }
159
190
  pruned[ws] = sock;
160
191
  }
161
192
  const resurrected = new Map();
193
+ // Stagger actual spawns: every freshly started daemon runs its checkout
194
+ // sync and reconcile immediately, and a burst of them after a reboot is an
195
+ // IO storm (same incident as above). Rows whose socket is already alive
196
+ // cost nothing and are not delayed.
197
+ let spawnedOne = false;
162
198
  for (const { ws, sock } of resurrectionPlan(pruned)) {
199
+ const alive = await isSocketAlive(sock);
200
+ if (!alive && spawnedOne)
201
+ await new Promise((r) => setTimeout(r, 2000));
163
202
  resurrected.set(sock, await resurrectDaemon(ws, sock));
203
+ if (!alive)
204
+ spawnedOne = true;
164
205
  }
165
206
  for (const [ws, sock] of Object.entries(pruned)) {
166
207
  if (resurrected.get(sock))
167
208
  live[ws] = sock;
168
209
  }
169
- 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;
170
250
  }
171
251
  async function startMcp() {
172
252
  const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);