vexp-cli 3.1.2 → 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/cli.js CHANGED
@@ -74,6 +74,14 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
74
74
  // 2026-09-05). Doing nothing quietly is the one outcome that cannot be
75
75
  // told apart from success.
76
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;
77
85
  console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
78
86
  console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
79
87
  return;
@@ -645,8 +653,9 @@ program
645
653
  program
646
654
  .command("doctor")
647
655
  .description("Diagnose vexp MCP/daemon state — workspace drift, stale daemons, Codex transport, license (no daemon needed)")
648
- .action(async () => {
649
- 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 });
650
659
  });
651
660
  program
652
661
  .command("setup [dir]")
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);
@@ -345,7 +373,206 @@ export function workspaceCoverageFindings(root) {
345
373
  }
346
374
  return out;
347
375
  }
348
- 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) {
349
576
  const home = vexpHome();
350
577
  const regPath = path.join(home, ".vexp", "daemons.json");
351
578
  let registry = {};
@@ -370,6 +597,7 @@ export async function runDoctor() {
370
597
  const ws = member
371
598
  ? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
372
599
  : resolved;
600
+ onWorkspace(ws.root);
373
601
  console.log(` cwd: ${process.cwd()}`);
374
602
  if (member) {
375
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`);
@@ -393,6 +621,18 @@ export async function runDoctor() {
393
621
  line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
394
622
  if (!live)
395
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
+ }
396
636
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
397
637
  // say "LLM enabled" while the daemon serves rule-compressed output (config
398
638
  // race / stale daemon / non-LLM build); only the daemon knows what's active.
@@ -658,6 +898,17 @@ export async function runDoctor() {
658
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"));
659
899
  }
660
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
+ }
661
912
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
662
913
  // just check presence. A shell-form command that word-splits on a project
663
914
  // path containing a space fails non-blocking on every call: the guard never
@@ -1028,14 +1279,23 @@ export async function runDoctor() {
1028
1279
  // since before the upgrade, holding its own version in memory
1029
1280
  // (field report, 2026-09-06).
1030
1281
  const staleSupervisorLine = (rec) => {
1031
- const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
1032
- if (!by || by === CLI_VERSION)
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.`);
1033
1291
  return;
1034
- const pid = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
1035
- const stillUp = pid !== undefined && isAlive(pid);
1292
+ }
1036
1293
  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"})` : "") +
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"})` : "") +
1039
1299
  `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
1040
1300
  // Ending it is the whole remediation: login autostart (or the next
1041
1301
  // `vexp` command) starts a fresh supervisor, which reads the current
@@ -1044,7 +1304,17 @@ export async function runDoctor() {
1044
1304
  // else — and the `vexp setup` this line used to recommend blocks on
1045
1305
  // an interactive prompt, which for the reader of a doctor line is a
1046
1306
  // 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` : ""));
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
+ : ""));
1048
1318
  };
1049
1319
  // 6) HTTP MCP supervisor.
1050
1320
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
@@ -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;
@@ -112,6 +112,29 @@ export function resolveOrGenerateMcpToken() {
112
112
  * ships it — the build a client will actually be served, as opposed to the
113
113
  * version of whatever process happens to be spawning it.
114
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
+ }
115
138
  export function mcpBundleVersion(mcpPath) {
116
139
  const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
117
140
  try {
@@ -167,6 +190,18 @@ export async function ensureMcpHttpServer(opts = {}) {
167
190
  if (!isOlderVersion(existing.version, CLI_VERSION)) {
168
191
  return { pid: existing.pid, port, started: false };
169
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
+ }
170
205
  try {
171
206
  process.kill(existing.pid, "SIGTERM");
172
207
  }
@@ -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");