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.
@@ -2918,7 +2918,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2918
2918
  let root = {};
2919
2919
  if (fs.existsSync(hooksJsonPath)) {
2920
2920
  try {
2921
- root = JSON.parse(fs.readFileSync(hooksJsonPath, "utf-8"));
2921
+ // parseJsonc, not JSON.parse: every other config reader here is
2922
+ // BOM-tolerant and this one was missed. Windows PowerShell 5.1 writes
2923
+ // JSON with a UTF-8 BOM, and a user who edits .codex/hooks.json by hand
2924
+ // there would have the hook silently left unregistered.
2925
+ root = parseJsonc(fs.readFileSync(hooksJsonPath, "utf-8"));
2922
2926
  }
2923
2927
  catch {
2924
2928
  warnUnparseable(hooksJsonPath);
package/dist/cli.js CHANGED
@@ -66,13 +66,32 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
66
66
  // the parent, so we boot (or reuse) the parent daemon instead of spawning
67
67
  // a conflicting one for the child.
68
68
  const eff = findEffectiveWorkspace();
69
- if (!eff)
69
+ // Say so. Bootstrapping is what replaces a stale daemon and takes over a
70
+ // stale MCP server on :7821, and it is silently skipped outside a
71
+ // configured workspace: a tester ran the command doctor had suggested from
72
+ // a package subdirectory of a monorepo, got no error and no takeover, and
73
+ // reasonably concluded the server had been replaced (tier-4 field report,
74
+ // 2026-09-05). Doing nothing quietly is the one outcome that cannot be
75
+ // told apart from success.
76
+ if (!eff) {
77
+ // `index` and `init` are the commands that CREATE a workspace, so telling
78
+ // them to go run `vexp setup` first is advice the command is in the middle
79
+ // of superseding — and it read as a contradiction to a user watching one
80
+ // invocation warn that no workspace exists and the next one start a daemon
81
+ // for it (field report, 2026-09-08). The bootstrap still does nothing here;
82
+ // it just does not misdirect.
83
+ if (actionCmd.name() === "index" || actionCmd.name() === "init")
84
+ return;
85
+ console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
86
+ console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
70
87
  return;
88
+ }
71
89
  let binaryPath;
72
90
  try {
73
91
  binaryPath = getBinaryPath();
74
92
  }
75
- catch {
93
+ catch (err) {
94
+ console.error(chalk.yellow(` ⚠ vexp-core binary not resolved (${err instanceof Error ? err.message : err}) — nothing was started or updated.`));
76
95
  return;
77
96
  }
78
97
  // Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
@@ -274,7 +293,12 @@ async function ensureBootstrap(workspaceRoot, binaryPath) {
274
293
  try {
275
294
  await ensureMcpHttpServer({ owner: "cli" });
276
295
  }
277
- catch { /* non-fatal */ }
296
+ catch (err) {
297
+ // Non-fatal, but never silent: this is the step that replaces an MCP
298
+ // server left behind by an older install, and skipping it leaves every
299
+ // MCP client on the old build.
300
+ console.error(chalk.dim(` ⚠ MCP HTTP server not started or taken over: ${err instanceof Error ? err.message : err}`));
301
+ }
278
302
  }
279
303
  /**
280
304
  * Start the vexp daemon and MCP HTTP server in the background.
@@ -629,8 +653,9 @@ program
629
653
  program
630
654
  .command("doctor")
631
655
  .description("Diagnose vexp MCP/daemon state — workspace drift, stale daemons, Codex transport, license (no daemon needed)")
632
- .action(async () => {
633
- await runDoctor();
656
+ .option("--report [file]", "Also write a shareable Markdown report: this diagnosis, the local tool-call ledger (counts and timings, never contents), daemon log warnings, config — secrets masked (default: .vexp/vexp-report.md)")
657
+ .action(async (opts) => {
658
+ await runDoctor({ report: opts.report });
634
659
  });
635
660
  program
636
661
  .command("setup [dir]")
@@ -725,6 +750,21 @@ program
725
750
  selectedNames = requested.map((n) => resolveAgentName(n));
726
751
  console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
727
752
  }
753
+ else if (!process.stdin.isTTY) {
754
+ // No one is at the keyboard. This command is named as a remediation
755
+ // in doctor's own output, and an agent — or a CI step, or a script
756
+ // reading that line — cannot answer a prompt: it hangs until it is
757
+ // killed, which is worse than failing (field report, 2026-09-07: a
758
+ // tester followed doctor's advice and had to abandon the command).
759
+ // Configure what is detected, and say that is what happened.
760
+ selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
761
+ if (selectedNames.length === 0) {
762
+ console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
763
+ console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
764
+ process.exit(1);
765
+ }
766
+ console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
767
+ }
728
768
  else {
729
769
  // Interactive selection
730
770
  console.log(chalk.bold("\n Select AI agents to configure:\n"));
package/dist/doctor.js CHANGED
@@ -4,10 +4,12 @@ import * as path from "path";
4
4
  import * as net from "net";
5
5
  import { spawnSync } from "child_process";
6
6
  import chalk from "chalk";
7
- import { socketPathFor } from "./socket-path.js";
7
+ import { socketPathFor, socketPathMargin } from "./socket-path.js";
8
8
  import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath } from "./agent-config.js";
9
9
  import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
10
10
  import { CLI_VERSION } from "./version.js";
11
+ import { parentPid } from "./mcp-supervisor.js";
12
+ import { slowMountNotice } from "./slow-mount.js";
11
13
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
12
14
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
13
15
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -98,6 +100,32 @@ function jwtExp(file) {
98
100
  return null;
99
101
  }
100
102
  }
103
+ /**
104
+ * What doctor should say about the process that spawned the MCP server.
105
+ *
106
+ * Pure, and exported, because this guard has now been wrong twice in the same
107
+ * way. The first version compared the recorded `spawnedBy` against ours and
108
+ * returned on anything else — so a record written BEFORE that field existed
109
+ * took the same branch as a record that matches, and the disclosure was
110
+ * silent on the one upgrade it exists for: the first one after it shipped
111
+ * (field report, 2026-09-08).
112
+ *
113
+ * "I cannot tell" and "there is no mismatch" are different answers and must
114
+ * never share a branch again.
115
+ */
116
+ export function supervisorVerdict(rec, cliVersion, io) {
117
+ const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
118
+ if (by === cliVersion)
119
+ return { kind: "ok" };
120
+ // The supervisor is the PARENT of the server, which is observable whatever
121
+ // wrote the record — and is how the field report identified it.
122
+ const recorded = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
123
+ const pid = recorded ?? (typeof rec.pid === "number" ? io.parentOf(rec.pid) : undefined);
124
+ if (pid === undefined) {
125
+ return by ? { kind: "stale", by } : { kind: "unknown" };
126
+ }
127
+ return { kind: "stale", by, pid, alive: io.alive(pid) };
128
+ }
101
129
  function isAlive(pid) {
102
130
  try {
103
131
  process.kill(pid, 0);
@@ -207,7 +235,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
207
235
  * wherever it stands. Null when nothing was skipped for size, or when the
208
236
  * file predates 3.1 and has no skip keys.
209
237
  */
210
- export function coverageVerdict(cov) {
238
+ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
211
239
  if (!cov || typeof cov !== "object")
212
240
  return null;
213
241
  const c = cov;
@@ -225,7 +253,12 @@ export function coverageVerdict(cov) {
225
253
  level: WARN,
226
254
  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` +
227
255
  examples.map((e) => ` - ${e}`).join("\n") +
228
- (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
256
+ (more > 0 ? `\n … +${more} more` : "") +
257
+ // The path, not the name. coverage.json lives at the WORKSPACE root;
258
+ // a tester on a monorepo looked for it beside the package he was
259
+ // working in, found nothing, and concluded the feature had not
260
+ // shipped (tier-4 field report, 2026-09-05).
261
+ `\n full list: ${coveragePath}` +
229
262
  `\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').`,
230
263
  };
231
264
  }
@@ -334,13 +367,212 @@ export function workspaceCoverageFindings(root) {
334
367
  catch {
335
368
  continue; /* never indexed, or an index older than 2.7 */
336
369
  }
337
- const v = coverageVerdict(cov);
370
+ const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
338
371
  if (v)
339
372
  out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
340
373
  }
341
374
  return out;
342
375
  }
343
- export async function runDoctor() {
376
+ export const SUPPORT_EMAIL = "staff@vexp.dev";
377
+ export const DEFAULT_REPORT_FILE = path.join(".vexp", "vexp-report.md");
378
+ // eslint-disable-next-line no-control-regex
379
+ const ANSI = /\x1b\[[0-9;]*m/g;
380
+ export function stripAnsi(s) {
381
+ return s.replace(ANSI, "");
382
+ }
383
+ /**
384
+ * Mask secret-shaped values in text that leaves the machine. The Rust
385
+ * shield (`shield::redact_secrets`) is the reference; this is the subset a
386
+ * doctor report needs: known token shapes and KEY=value assignments. Never
387
+ * enough of the value to reconstruct it, always enough to see there was one.
388
+ */
389
+ const SECRET_SHAPES = [
390
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
391
+ /\b(?:ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
392
+ /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g,
393
+ /\bAKIA[A-Z0-9]{16}\b/g, // AWS access key id
394
+ /\b(?:sk|pylf|xox[abp])[-_][A-Za-z0-9_-]{12,}\b/g, // OpenAI / Logfire / Slack shapes
395
+ ];
396
+ const ASSIGNED_SECRET = /((?:api[_-]?key|apikey|token|secret|password|passwd|credential|private[_-]?key|access[_-]?key)[A-Za-z0-9_-]*\s*[=:]\s*["']?)([^\s"',;)}]{8,})/gi;
397
+ function maskValue(v) {
398
+ return v.length <= 6 ? "*".repeat(v.length) : `${v.slice(0, 3)}***${v.slice(-2)}`;
399
+ }
400
+ export function maskSecretsForReport(text) {
401
+ let out = text;
402
+ for (const re of SECRET_SHAPES)
403
+ out = out.replace(re, (m) => maskValue(m));
404
+ out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
405
+ return out;
406
+ }
407
+ /**
408
+ * The report a tester can hand over without editing it first.
409
+ *
410
+ * Field request (2026-09-09): "make a way to ask AI to generate a usage
411
+ * report into an .md file that can be sent to you. that way you can get
412
+ * great bug reports" — from a user whose agent had just done exactly that
413
+ * by reading index.db by hand. Sections: what doctor saw, the local
414
+ * tool-call ledger (counts and timings, never prompt or file contents), the
415
+ * daemon log's warnings, the config. Secret-shaped values are masked
416
+ * everywhere; paths stay, and the header says so.
417
+ */
418
+ export function buildDoctorReport(i) {
419
+ const head = [
420
+ `Generated ${i.generatedAt} · vexp-cli ${i.cliVersion} · binary ${i.binaryVersion ?? "not runnable"} · ${i.platform} · node ${i.nodeVersion}${i.wsl ? " · WSL" : ""}`,
421
+ `Workspace: ${i.root}`,
422
+ ].join("\n");
423
+ const parts = [
424
+ "# vexp report",
425
+ "",
426
+ head,
427
+ "",
428
+ "Written by `vexp doctor --report`. It holds the doctor output, the local tool-call ledger " +
429
+ "(counts and timings only — never prompt text or file contents), the daemon log's warnings " +
430
+ "and errors, and the vexp config. Secret-shaped values are masked; file paths are visible. " +
431
+ `Review it, then send it to ${SUPPORT_EMAIL} or attach it to your issue.`,
432
+ ];
433
+ if (i.mountNotice)
434
+ parts.push("", `> **Filesystem:** ${i.mountNotice}`);
435
+ parts.push("", "## Doctor", "", "```", stripAnsi(i.doctorText).trim(), "```");
436
+ parts.push("", "## Usage ledger", "");
437
+ parts.push(i.ledgerMarkdown?.trim() || `ledger unavailable: ${i.ledgerError ?? "unknown error"}`);
438
+ parts.push("", `## Daemon log${i.logName ? ` (.vexp/${i.logName})` : ""}: warnings and errors`, "");
439
+ if (!i.logName)
440
+ parts.push("no daemon log found under .vexp/");
441
+ else if (i.logWarnings.length === 0)
442
+ parts.push("no warnings or errors in the last 3000 lines");
443
+ else
444
+ parts.push("```", ...i.logWarnings, "```");
445
+ parts.push("", "## Config (.vexp/vexp.toml)", "");
446
+ parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
447
+ parts.push("");
448
+ return maskSecretsForReport(parts.join("\n"));
449
+ }
450
+ /**
451
+ * Warnings and errors from the daemon log tail, ANSI stripped, newest last.
452
+ * Repeats of one message (the same config warning on every start) collapse
453
+ * into the latest occurrence with a count: a report is read by a person,
454
+ * and eighty identical lines hide the one that differs.
455
+ */
456
+ export function logWarningsFrom(text, keep = 80, scan = 3000) {
457
+ const lines = stripAnsi(text).trimEnd().split("\n").slice(-scan);
458
+ const hits = lines.filter((l) => /\b(WARN|ERROR|panicked)\b/.test(l));
459
+ const byMessage = new Map();
460
+ for (const l of hits) {
461
+ const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
462
+ const cur = byMessage.get(key);
463
+ if (cur) {
464
+ cur.last = l;
465
+ cur.count++;
466
+ byMessage.delete(key); // re-insert so the map stays in last-seen order
467
+ byMessage.set(key, cur);
468
+ }
469
+ else
470
+ byMessage.set(key, { last: l, count: 1 });
471
+ }
472
+ return [...byMessage.values()]
473
+ .map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
474
+ .slice(-keep);
475
+ }
476
+ /** Gather every input on this machine and write the report. Returns its path. */
477
+ export async function writeDoctorReport(o) {
478
+ let bin = null;
479
+ try {
480
+ const { getBinaryPath } = await import("./binary.js");
481
+ bin = getBinaryPath();
482
+ }
483
+ catch {
484
+ /* no binary: the report says so and still carries everything else */
485
+ }
486
+ const run = (args, timeout) => bin ? spawnSync(bin, args, { cwd: o.root, timeout, encoding: "utf-8" }) : null;
487
+ const ver = run(["--version"], 5000);
488
+ const binaryVersion = ver && ver.status === 0 ? String(ver.stdout).trim().split(/\s+/).pop() ?? null : null;
489
+ const ledger = run(["usage-report", "--days", "30"], 30000);
490
+ let ledgerMarkdown = null;
491
+ let ledgerError;
492
+ if (!ledger)
493
+ ledgerError = "vexp-core binary not found";
494
+ else if (ledger.error)
495
+ ledgerError = ledger.error.message;
496
+ else if (ledger.status !== 0) {
497
+ const err = String(ledger.stderr ?? "").trim().split("\n")[0];
498
+ ledgerError = /unrecognized subcommand|unexpected argument/.test(err)
499
+ ? `binary ${binaryVersion ?? "?"} predates usage-report (3.1.3) — update vexp-core`
500
+ : err || `exit ${ledger.status}`;
501
+ }
502
+ else
503
+ ledgerMarkdown = String(ledger.stdout);
504
+ let logName = null;
505
+ let logWarnings = [];
506
+ for (const name of ["daemon.log", "vexp.log"]) {
507
+ try {
508
+ const text = fs.readFileSync(path.join(o.root, ".vexp", name), "utf-8");
509
+ logName = name;
510
+ logWarnings = logWarningsFrom(text);
511
+ break;
512
+ }
513
+ catch {
514
+ /* try the next name */
515
+ }
516
+ }
517
+ let configText = null;
518
+ try {
519
+ configText = fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml"), "utf-8");
520
+ }
521
+ catch {
522
+ /* absent: defaults */
523
+ }
524
+ const md = buildDoctorReport({
525
+ generatedAt: new Date().toISOString(),
526
+ cliVersion: CLI_VERSION,
527
+ binaryVersion,
528
+ platform: `${process.platform} ${process.arch}`,
529
+ nodeVersion: process.version,
530
+ wsl: isWsl(),
531
+ root: o.root,
532
+ doctorText: o.doctorText,
533
+ ledgerMarkdown,
534
+ ledgerError,
535
+ logName,
536
+ logWarnings,
537
+ configText,
538
+ mountNotice: slowMountNotice(o.root),
539
+ });
540
+ const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
541
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
542
+ fs.writeFileSync(dest, md);
543
+ return dest;
544
+ }
545
+ export async function runDoctor(opts = {}) {
546
+ // --report: tee everything doctor prints into the report, colors stripped.
547
+ const captured = [];
548
+ const origLog = console.log;
549
+ if (opts.report) {
550
+ console.log = (...args) => {
551
+ origLog(...args);
552
+ captured.push(stripAnsi(args.map((a) => String(a)).join(" ")));
553
+ };
554
+ }
555
+ let reportRoot = null;
556
+ try {
557
+ await doctorChecks((root) => {
558
+ reportRoot = root;
559
+ });
560
+ }
561
+ finally {
562
+ console.log = origLog;
563
+ }
564
+ if (opts.report) {
565
+ const dest = await writeDoctorReport({
566
+ root: reportRoot ?? process.cwd(),
567
+ doctorText: captured.join("\n"),
568
+ outFile: typeof opts.report === "string" ? opts.report : undefined,
569
+ });
570
+ const kb = Math.max(1, Math.round(fs.statSync(dest).size / 1024));
571
+ console.log(` [${OK}] report written to ${dest} (${kb} KB) — review it, then send it to ${SUPPORT_EMAIL}`);
572
+ console.log("");
573
+ }
574
+ }
575
+ async function doctorChecks(onWorkspace) {
344
576
  const home = vexpHome();
345
577
  const regPath = path.join(home, ".vexp", "daemons.json");
346
578
  let registry = {};
@@ -365,6 +597,7 @@ export async function runDoctor() {
365
597
  const ws = member
366
598
  ? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
367
599
  : resolved;
600
+ onWorkspace(ws.root);
368
601
  console.log(` cwd: ${process.cwd()}`);
369
602
  if (member) {
370
603
  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`);
@@ -388,15 +621,36 @@ export async function runDoctor() {
388
621
  line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
389
622
  if (!live)
390
623
  console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
624
+ // The /tmp relocation is silent by construction — every client computes
625
+ // the same fallback and none of them says so. A tester measured his
626
+ // Claude Code worktrees at 89 of the 100 characters and pointed out that
627
+ // a longer auto-generated name would move the socket with nothing
628
+ // announcing it. Announce it here, and the margin before it.
629
+ const m = socketPathMargin(ws.root);
630
+ if (m.relocated) {
631
+ line(OK, `socket lives in /tmp because ${m.candidate} is ${m.length} chars, over the ${m.limit} a Unix socket path allows — every vexp client computes the same fallback, nothing is missing`);
632
+ }
633
+ else if (Number.isFinite(m.margin) && m.margin <= 15) {
634
+ line(WARN, `socket path is ${m.length} of ${m.limit} chars (${m.margin} to spare): a longer directory name — a Claude Code worktree is auto-named — moves this workspace's socket to /tmp/vexp-<hash>.sock. Nothing breaks; when a daemon looks absent, read the 'daemon socket' line above first`);
635
+ }
391
636
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
392
637
  // say "LLM enabled" while the daemon serves rule-compressed output (config
393
638
  // race / stale daemon / non-LLM build); only the daemon knows what's active.
394
639
  if (live) {
395
640
  const st = await queryDaemon(sock, "index_status");
396
641
  if (st) {
642
+ // A daemon that is still loading its model answers "rule", which reads
643
+ // as a downgrade for the ~20s it takes to settle. The daemon already
644
+ // says which of the two it is; doctor was printing the label and
645
+ // dropping the reason (tier-4 field report, 2026-09-05).
646
+ const loading = st.compressor !== "llm"
647
+ && typeof st.llm_inactive_reason === "string"
648
+ && /still loading|finishing its startup/i.test(st.llm_inactive_reason);
397
649
  const comp = st.compressor === "llm"
398
650
  ? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
399
- : st.compressor ?? "unknown (older daemon)";
651
+ : loading
652
+ ? "loading (the model is still starting — this is not the rule compressor settling in)"
653
+ : st.compressor ?? "unknown (older daemon)";
400
654
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
401
655
  if (st.llm_configured_but_inactive === true) {
402
656
  // The daemon knows WHY, and doctor was throwing it away: it printed
@@ -644,6 +898,17 @@ export async function runDoctor() {
644
898
  console.log(chalk.dim(" Windows-side apps (Codex app, Claude Desktop, Windsurf) need a wsl.exe bridge entry: https://vexp.dev/docs#windows-wsl"));
645
899
  }
646
900
  }
901
+ // 5a-ter) Filesystem: a workspace behind an OS boundary (9p, drvfs, a
902
+ // network share) makes every vexp call 10-50x slower and reads as a vexp
903
+ // bug (field report, 2026-09-09: verify_done at 41-170 s on /app over 9p,
904
+ // every other line of doctor green).
905
+ {
906
+ const slow = slowMountNotice(ws.root);
907
+ if (slow) {
908
+ console.log(chalk.bold("\nFilesystem"));
909
+ line(WARN, slow);
910
+ }
911
+ }
647
912
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
648
913
  // just check presence. A shell-form command that word-splits on a project
649
914
  // path containing a space fails non-blocking on every call: the guard never
@@ -1004,22 +1269,72 @@ export async function runDoctor() {
1004
1269
  }
1005
1270
  }
1006
1271
  }
1272
+ // A supervisor from an older install respawning the current bundle. It
1273
+ // keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
1274
+ // stop it: the old process runs the old comparison. The only fix is to end
1275
+ // it, so doctor has to name it — with its pid, which the record now
1276
+ // carries. A tester spent a night on a :7821 server that came back with a
1277
+ // new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
1278
+ // was not installed anywhere: the LABEL came from a `vexp serve` resident
1279
+ // since before the upgrade, holding its own version in memory
1280
+ // (field report, 2026-09-06).
1281
+ const staleSupervisorLine = (rec) => {
1282
+ const v = supervisorVerdict(rec, CLI_VERSION, {
1283
+ parentOf: parentPid,
1284
+ alive: isAlive,
1285
+ });
1286
+ if (v.kind === "ok")
1287
+ return;
1288
+ if (v.kind === "unknown") {
1289
+ line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
1290
+ ` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
1291
+ return;
1292
+ }
1293
+ const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
1294
+ const provenance = v.by
1295
+ ? `running v${v.by}, not v${CLI_VERSION}`
1296
+ : `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
1297
+ line(WARN, `that server was spawned by a ${who} ${provenance}` +
1298
+ (v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
1299
+ `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
1300
+ // Ending it is the whole remediation: login autostart (or the next
1301
+ // `vexp` command) starts a fresh supervisor, which reads the current
1302
+ // package.json and serves the current build. The tester who did this
1303
+ // had a correct server one second later without running anything
1304
+ // else — and the `vexp setup` this line used to recommend blocks on
1305
+ // an interactive prompt, which for the reader of a doctor line is a
1306
+ // hang, not a fix (field report, 2026-09-07).
1307
+ (v.alive
1308
+ ? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
1309
+ // The order is load-bearing and nothing said so: an old
1310
+ // supervisor's health loop rebuilds the daemon registry every 60s
1311
+ // from its own rows, so it deletes each daemon's registration as
1312
+ // fast as the daemon writes it. Restart the daemons first and they
1313
+ // come back invisible — and nobody restarts a daemon they have
1314
+ // just restarted (field report, 2026-09-08).
1315
+ `\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
1316
+ `\n restarted before it is gone comes back invisible to 'vexp daemons'.`
1317
+ : ""));
1318
+ };
1007
1319
  // 6) HTTP MCP supervisor.
1008
1320
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
1009
1321
  try {
1010
1322
  const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
1011
1323
  const alive = isAlive(rec.pid);
1012
1324
  line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
1325
+ if (alive && rec.version === CLI_VERSION)
1326
+ staleSupervisorLine(rec);
1013
1327
  // The supervisor is replaced on version mismatch only when a vexp command
1014
1328
  // next runs; until then every HTTP client (Codex http transport, a
1015
1329
  // `serverUrl` entry) talks to the OLD build — which advertises the old
1016
1330
  // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
1017
1331
  // saw two vexp servers with different tool counts and could not tell why.
1018
1332
  if (alive && rec.version && rec.version !== CLI_VERSION) {
1019
- 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' (or any indexing/search command, e.g. 'vexp savings') in the project and the current CLI takes it over`);
1333
+ 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`);
1334
+ staleSupervisorLine(rec);
1020
1335
  }
1021
1336
  else if (alive && !rec.version) {
1022
- line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup' (or 'vexp savings') in the project so the current CLI replaces it and HTTP clients get v${CLI_VERSION}`);
1337
+ 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}`);
1023
1338
  }
1024
1339
  }
1025
1340
  catch {