vexp-cli 3.1.2 → 3.2.0

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
@@ -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,232 @@ export function workspaceCoverageFindings(root) {
345
373
  }
346
374
  return out;
347
375
  }
348
- export async function runDoctor() {
376
+ /**
377
+ * The 7-day activity ledger as doctor lines. Zero calls is not "not
378
+ * working": silence on oriented prompts is vexp doing its job, so the OK
379
+ * line exists whenever prompts were analyzed. The WARN is the other half of
380
+ * the same ledger: orientations the daemon finished after the hook client
381
+ * (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
382
+ * never read by the agent (field ledger, 3.1.3: up to 8 of 33).
383
+ */
384
+ export function ledgerFindings(ledger) {
385
+ const out = [];
386
+ const analyzed = Number(ledger.prompts_analyzed) || 0;
387
+ if (analyzed > 0) {
388
+ out.push({
389
+ level: OK,
390
+ message: `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`,
391
+ });
392
+ }
393
+ const late = Number(ledger.late) || 0;
394
+ if (late > 0) {
395
+ out.push({
396
+ level: WARN,
397
+ message: `${late} orientation(s) in the last 7 days finished after the hook client's budget (3 s by default) and never reached the agent - long prompts are slow on this index; vexp savings shows the count`,
398
+ });
399
+ }
400
+ return out;
401
+ }
402
+ export const SUPPORT_EMAIL = "staff@vexp.dev";
403
+ export const DEFAULT_REPORT_FILE = path.join(".vexp", "vexp-report.md");
404
+ // eslint-disable-next-line no-control-regex
405
+ const ANSI = /\x1b\[[0-9;]*m/g;
406
+ export function stripAnsi(s) {
407
+ return s.replace(ANSI, "");
408
+ }
409
+ /**
410
+ * Mask secret-shaped values in text that leaves the machine. The Rust
411
+ * shield (`shield::redact_secrets`) is the reference; this is the subset a
412
+ * doctor report needs: known token shapes and KEY=value assignments. Never
413
+ * enough of the value to reconstruct it, always enough to see there was one.
414
+ */
415
+ const SECRET_SHAPES = [
416
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
417
+ /\b(?:ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
418
+ /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g,
419
+ /\bAKIA[A-Z0-9]{16}\b/g, // AWS access key id
420
+ /\b(?:sk|pylf|xox[abp])[-_][A-Za-z0-9_-]{12,}\b/g, // OpenAI / Logfire / Slack shapes
421
+ ];
422
+ const ASSIGNED_SECRET = /((?:api[_-]?key|apikey|token|secret|password|passwd|credential|private[_-]?key|access[_-]?key)[A-Za-z0-9_-]*\s*[=:]\s*["']?)([^\s"',;)}]{8,})/gi;
423
+ function maskValue(v) {
424
+ return v.length <= 6 ? "*".repeat(v.length) : `${v.slice(0, 3)}***${v.slice(-2)}`;
425
+ }
426
+ export function maskSecretsForReport(text) {
427
+ let out = text;
428
+ for (const re of SECRET_SHAPES)
429
+ out = out.replace(re, (m) => maskValue(m));
430
+ out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
431
+ return out;
432
+ }
433
+ /**
434
+ * The report a tester can hand over without editing it first.
435
+ *
436
+ * Field request (2026-09-09): "make a way to ask AI to generate a usage
437
+ * report into an .md file that can be sent to you. that way you can get
438
+ * great bug reports" — from a user whose agent had just done exactly that
439
+ * by reading index.db by hand. Sections: what doctor saw, the local
440
+ * tool-call ledger (counts and timings, never prompt or file contents), the
441
+ * daemon log's warnings, the config. Secret-shaped values are masked
442
+ * everywhere; paths stay, and the header says so.
443
+ */
444
+ export function buildDoctorReport(i) {
445
+ const head = [
446
+ `Generated ${i.generatedAt} · vexp-cli ${i.cliVersion} · binary ${i.binaryVersion ?? "not runnable"} · ${i.platform} · node ${i.nodeVersion}${i.wsl ? " · WSL" : ""}`,
447
+ `Workspace: ${i.root}`,
448
+ ].join("\n");
449
+ const parts = [
450
+ "# vexp report",
451
+ "",
452
+ head,
453
+ "",
454
+ "Written by `vexp doctor --report`. It holds the doctor output, the local tool-call ledger " +
455
+ "(counts and timings only — never prompt text or file contents), the daemon log's warnings " +
456
+ "and errors, and the vexp config. Secret-shaped values are masked; file paths are visible. " +
457
+ `Review it, then send it to ${SUPPORT_EMAIL} or attach it to your issue.`,
458
+ ];
459
+ if (i.mountNotice)
460
+ parts.push("", `> **Filesystem:** ${i.mountNotice}`);
461
+ parts.push("", "## Doctor", "", "```", stripAnsi(i.doctorText).trim(), "```");
462
+ parts.push("", "## Usage ledger", "");
463
+ parts.push(i.ledgerMarkdown?.trim() || `ledger unavailable: ${i.ledgerError ?? "unknown error"}`);
464
+ parts.push("", `## Daemon log${i.logName ? ` (.vexp/${i.logName})` : ""}: warnings and errors`, "");
465
+ if (!i.logName)
466
+ parts.push("no daemon log found under .vexp/");
467
+ else if (i.logWarnings.length === 0)
468
+ parts.push("no warnings or errors in the last 3000 lines");
469
+ else
470
+ parts.push("```", ...i.logWarnings, "```");
471
+ parts.push("", "## Config (.vexp/vexp.toml)", "");
472
+ parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
473
+ parts.push("");
474
+ return maskSecretsForReport(parts.join("\n"));
475
+ }
476
+ /**
477
+ * Warnings and errors from the daemon log tail, ANSI stripped, newest last.
478
+ * Repeats of one message (the same config warning on every start) collapse
479
+ * into the latest occurrence with a count: a report is read by a person,
480
+ * and eighty identical lines hide the one that differs.
481
+ */
482
+ export function logWarningsFrom(text, keep = 80, scan = 3000) {
483
+ const lines = stripAnsi(text).trimEnd().split("\n").slice(-scan);
484
+ const hits = lines.filter((l) => /\b(WARN|ERROR|panicked)\b/.test(l));
485
+ const byMessage = new Map();
486
+ for (const l of hits) {
487
+ const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
488
+ const cur = byMessage.get(key);
489
+ if (cur) {
490
+ cur.last = l;
491
+ cur.count++;
492
+ byMessage.delete(key); // re-insert so the map stays in last-seen order
493
+ byMessage.set(key, cur);
494
+ }
495
+ else
496
+ byMessage.set(key, { last: l, count: 1 });
497
+ }
498
+ return [...byMessage.values()]
499
+ .map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
500
+ .slice(-keep);
501
+ }
502
+ /** Gather every input on this machine and write the report. Returns its path. */
503
+ export async function writeDoctorReport(o) {
504
+ let bin = null;
505
+ try {
506
+ const { getBinaryPath } = await import("./binary.js");
507
+ bin = getBinaryPath();
508
+ }
509
+ catch {
510
+ /* no binary: the report says so and still carries everything else */
511
+ }
512
+ const run = (args, timeout) => bin ? spawnSync(bin, args, { cwd: o.root, timeout, encoding: "utf-8" }) : null;
513
+ const ver = run(["--version"], 5000);
514
+ const binaryVersion = ver && ver.status === 0 ? String(ver.stdout).trim().split(/\s+/).pop() ?? null : null;
515
+ const ledger = run(["usage-report", "--days", "30"], 30000);
516
+ let ledgerMarkdown = null;
517
+ let ledgerError;
518
+ if (!ledger)
519
+ ledgerError = "vexp-core binary not found";
520
+ else if (ledger.error)
521
+ ledgerError = ledger.error.message;
522
+ else if (ledger.status !== 0) {
523
+ const err = String(ledger.stderr ?? "").trim().split("\n")[0];
524
+ ledgerError = /unrecognized subcommand|unexpected argument/.test(err)
525
+ ? `binary ${binaryVersion ?? "?"} predates usage-report (3.1.3) — update vexp-core`
526
+ : err || `exit ${ledger.status}`;
527
+ }
528
+ else
529
+ ledgerMarkdown = String(ledger.stdout);
530
+ let logName = null;
531
+ let logWarnings = [];
532
+ for (const name of ["daemon.log", "vexp.log"]) {
533
+ try {
534
+ const text = fs.readFileSync(path.join(o.root, ".vexp", name), "utf-8");
535
+ logName = name;
536
+ logWarnings = logWarningsFrom(text);
537
+ break;
538
+ }
539
+ catch {
540
+ /* try the next name */
541
+ }
542
+ }
543
+ let configText = null;
544
+ try {
545
+ configText = fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml"), "utf-8");
546
+ }
547
+ catch {
548
+ /* absent: defaults */
549
+ }
550
+ const md = buildDoctorReport({
551
+ generatedAt: new Date().toISOString(),
552
+ cliVersion: CLI_VERSION,
553
+ binaryVersion,
554
+ platform: `${process.platform} ${process.arch}`,
555
+ nodeVersion: process.version,
556
+ wsl: isWsl(),
557
+ root: o.root,
558
+ doctorText: o.doctorText,
559
+ ledgerMarkdown,
560
+ ledgerError,
561
+ logName,
562
+ logWarnings,
563
+ configText,
564
+ mountNotice: slowMountNotice(o.root),
565
+ });
566
+ const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
567
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
568
+ fs.writeFileSync(dest, md);
569
+ return dest;
570
+ }
571
+ export async function runDoctor(opts = {}) {
572
+ // --report: tee everything doctor prints into the report, colors stripped.
573
+ const captured = [];
574
+ const origLog = console.log;
575
+ if (opts.report) {
576
+ console.log = (...args) => {
577
+ origLog(...args);
578
+ captured.push(stripAnsi(args.map((a) => String(a)).join(" ")));
579
+ };
580
+ }
581
+ let reportRoot = null;
582
+ try {
583
+ await doctorChecks((root) => {
584
+ reportRoot = root;
585
+ });
586
+ }
587
+ finally {
588
+ console.log = origLog;
589
+ }
590
+ if (opts.report) {
591
+ const dest = await writeDoctorReport({
592
+ root: reportRoot ?? process.cwd(),
593
+ doctorText: captured.join("\n"),
594
+ outFile: typeof opts.report === "string" ? opts.report : undefined,
595
+ });
596
+ const kb = Math.max(1, Math.round(fs.statSync(dest).size / 1024));
597
+ console.log(` [${OK}] report written to ${dest} (${kb} KB) — review it, then send it to ${SUPPORT_EMAIL}`);
598
+ console.log("");
599
+ }
600
+ }
601
+ async function doctorChecks(onWorkspace) {
349
602
  const home = vexpHome();
350
603
  const regPath = path.join(home, ".vexp", "daemons.json");
351
604
  let registry = {};
@@ -370,6 +623,7 @@ export async function runDoctor() {
370
623
  const ws = member
371
624
  ? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
372
625
  : resolved;
626
+ onWorkspace(ws.root);
373
627
  console.log(` cwd: ${process.cwd()}`);
374
628
  if (member) {
375
629
  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 +647,18 @@ export async function runDoctor() {
393
647
  line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
394
648
  if (!live)
395
649
  console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
650
+ // The /tmp relocation is silent by construction — every client computes
651
+ // the same fallback and none of them says so. A tester measured his
652
+ // Claude Code worktrees at 89 of the 100 characters and pointed out that
653
+ // a longer auto-generated name would move the socket with nothing
654
+ // announcing it. Announce it here, and the margin before it.
655
+ const m = socketPathMargin(ws.root);
656
+ if (m.relocated) {
657
+ 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`);
658
+ }
659
+ else if (Number.isFinite(m.margin) && m.margin <= 15) {
660
+ 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`);
661
+ }
396
662
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
397
663
  // say "LLM enabled" while the daemon serves rule-compressed output (config
398
664
  // race / stale daemon / non-LLM build); only the daemon knows what's active.
@@ -463,9 +729,8 @@ export async function runDoctor() {
463
729
  // support tickets ("is it working? it never got called").
464
730
  const ledger = (st.ledger ?? {});
465
731
  const analyzed = Number(ledger.prompts_analyzed) || 0;
466
- if (analyzed > 0) {
467
- line(OK, `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`);
468
- }
732
+ for (const f of ledgerFindings(ledger))
733
+ line(f.level, f.message);
469
734
  if (sessions.length > 0) {
470
735
  const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
471
736
  line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
@@ -658,6 +923,17 @@ export async function runDoctor() {
658
923
  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
924
  }
660
925
  }
926
+ // 5a-ter) Filesystem: a workspace behind an OS boundary (9p, drvfs, a
927
+ // network share) makes every vexp call 10-50x slower and reads as a vexp
928
+ // bug (field report, 2026-09-09: verify_done at 41-170 s on /app over 9p,
929
+ // every other line of doctor green).
930
+ {
931
+ const slow = slowMountNotice(ws.root);
932
+ if (slow) {
933
+ console.log(chalk.bold("\nFilesystem"));
934
+ line(WARN, slow);
935
+ }
936
+ }
661
937
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
662
938
  // just check presence. A shell-form command that word-splits on a project
663
939
  // path containing a space fails non-blocking on every call: the guard never
@@ -1028,14 +1304,23 @@ export async function runDoctor() {
1028
1304
  // since before the upgrade, holding its own version in memory
1029
1305
  // (field report, 2026-09-06).
1030
1306
  const staleSupervisorLine = (rec) => {
1031
- const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
1032
- if (!by || by === CLI_VERSION)
1307
+ const v = supervisorVerdict(rec, CLI_VERSION, {
1308
+ parentOf: parentPid,
1309
+ alive: isAlive,
1310
+ });
1311
+ if (v.kind === "ok")
1033
1312
  return;
1034
- const pid = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
1035
- const stillUp = pid !== undefined && isAlive(pid);
1313
+ if (v.kind === "unknown") {
1314
+ line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
1315
+ ` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
1316
+ return;
1317
+ }
1036
1318
  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"})` : "") +
1319
+ const provenance = v.by
1320
+ ? `running v${v.by}, not v${CLI_VERSION}`
1321
+ : `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
1322
+ line(WARN, `that server was spawned by a ${who} ${provenance}` +
1323
+ (v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
1039
1324
  `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
1040
1325
  // Ending it is the whole remediation: login autostart (or the next
1041
1326
  // `vexp` command) starts a fresh supervisor, which reads the current
@@ -1044,7 +1329,17 @@ export async function runDoctor() {
1044
1329
  // else — and the `vexp setup` this line used to recommend blocks on
1045
1330
  // an interactive prompt, which for the reader of a doctor line is a
1046
1331
  // 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` : ""));
1332
+ (v.alive
1333
+ ? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
1334
+ // The order is load-bearing and nothing said so: an old
1335
+ // supervisor's health loop rebuilds the daemon registry every 60s
1336
+ // from its own rows, so it deletes each daemon's registration as
1337
+ // fast as the daemon writes it. Restart the daemons first and they
1338
+ // come back invisible — and nobody restarts a daemon they have
1339
+ // just restarted (field report, 2026-09-08).
1340
+ `\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
1341
+ `\n restarted before it is gone comes back invisible to 'vexp daemons'.`
1342
+ : ""));
1048
1343
  };
1049
1344
  // 6) HTTP MCP supervisor.
1050
1345
  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
  }
package/dist/serve.js CHANGED
@@ -75,6 +75,14 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
75
75
  return false;
76
76
  }
77
77
  try {
78
+ // `detached` stays. On Windows it is DETACHED_PROCESS: the daemon gets no
79
+ // console, which is what lets it outlive this supervisor — a non-detached
80
+ // child sits in libuv's kill-on-close job object and dies with `vexp
81
+ // serve`. A console-less parent's git children used to open a Windows
82
+ // Terminal window each (field report, 2026-09: 98 a minute); the cure is
83
+ // in vexp-core, which spawns every child with CREATE_NO_WINDOW
84
+ // (packages/vexp-core/src/proc.rs). `windowsHide` would not help here:
85
+ // Windows ignores it next to DETACHED_PROCESS.
78
86
  const child = spawn(binary, ["daemon", "--workspace", workspaceRoot, "--socket", socketPath], {
79
87
  detached: true,
80
88
  stdio: "ignore",
@@ -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");