vexp-cli 3.1.0 → 3.1.1

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;
@@ -223,7 +226,7 @@ export function coverageVerdict(cov) {
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
228
  (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.`,
229
+ `\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
230
  };
228
231
  }
229
232
  /**
@@ -266,8 +269,85 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
266
269
  }
267
270
  return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
268
271
  }
272
+ /**
273
+ * How doctor runs a Codex `UserPromptSubmit` hook line: the way Codex does.
274
+ * On Windows Codex hands `cmd.exe /C` the WHOLE line wrapped in one more pair
275
+ * of quotes, verbatim (codex-rs/hooks engine/command_runner: `raw_arg`), and
276
+ * cmd strips that outer pair — which is why the installer may quote the
277
+ * script path. Passing the line as a normal argument instead lets Node escape
278
+ * the inner quotes, and cmd then looks for a program literally named
279
+ * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
280
+ * failed doctor's probe and passed once the user removed the quotes).
281
+ */
282
+ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
283
+ if (platform === "win32") {
284
+ return { file: comspec || "cmd.exe", args: ["/C", `"${cmdLine}"`], windowsVerbatimArguments: true };
285
+ }
286
+ return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
287
+ }
288
+ function samePath(a, b) {
289
+ // The daemon registry lower-cases Windows roots; compare accordingly.
290
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
291
+ }
292
+ /**
293
+ * A connected repo of a multi-repo workspace has no daemon of its own: the
294
+ * parent's daemon serves it. Doctor run inside such a repo used to treat it as
295
+ * a standalone workspace — "daemon NOT reachable", "NOT registered" — while
296
+ * `vexp search` from the same directory answered fine (field report,
297
+ * 2026-09-01, nine repos). The link is `.vexp/parent_workspace.json` in the
298
+ * member; when that file is missing, any registered workspace whose
299
+ * workspace.json lists this directory is the parent.
300
+ */
301
+ export function resolveMemberParent(root, registryRoots) {
302
+ const link = resolveParentWorkspace(root);
303
+ if (link) {
304
+ const alias = listWorkspaceRepos(link.parentRoot).find((r) => samePath(r.resolvedPath, root))?.alias
305
+ ?? path.basename(root);
306
+ return { parentRoot: link.parentRoot, alias, via: ".vexp/parent_workspace.json" };
307
+ }
308
+ for (const reg of registryRoots) {
309
+ if (samePath(reg, root))
310
+ continue;
311
+ const hit = listWorkspaceRepos(reg).find((r) => !r.isPrimary && samePath(r.resolvedPath, root));
312
+ if (hit)
313
+ return { parentRoot: reg, alias: hit.alias, via: `${path.join(reg, ".vexp", "workspace.json")} (daemon registry)` };
314
+ }
315
+ return null;
316
+ }
317
+ /**
318
+ * Coverage verdicts for the workspace at `root` AND every repo connected to
319
+ * it, each tagged with its alias. Each repo's indexer writes its own
320
+ * `.vexp/coverage.json`; reading only the primary's hid three oversized files
321
+ * in a connected repo from a doctor run at the primary (same report).
322
+ */
323
+ export function workspaceCoverageFindings(root) {
324
+ const repos = listWorkspaceRepos(root);
325
+ const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
326
+ if (!targets.some((t) => t.primary))
327
+ targets.unshift({ alias: path.basename(root), dir: root, primary: true });
328
+ const out = [];
329
+ for (const t of targets) {
330
+ let cov;
331
+ try {
332
+ cov = JSON.parse(fs.readFileSync(path.join(t.dir, ".vexp", "coverage.json"), "utf-8"));
333
+ }
334
+ catch {
335
+ continue; /* never indexed, or an index older than 2.7 */
336
+ }
337
+ const v = coverageVerdict(cov);
338
+ if (v)
339
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
340
+ }
341
+ return out;
342
+ }
269
343
  export async function runDoctor() {
270
344
  const home = vexpHome();
345
+ const regPath = path.join(home, ".vexp", "daemons.json");
346
+ let registry = {};
347
+ try {
348
+ registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
349
+ }
350
+ catch { /* absent */ }
271
351
  let warns = 0;
272
352
  let fails = 0;
273
353
  const line = (status, msg) => {
@@ -280,8 +360,15 @@ export async function runDoctor() {
280
360
  console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
281
361
  // 1) Workspace resolution for the current directory.
282
362
  console.log(chalk.bold("Workspace targeting"));
283
- const ws = resolveWorkspace();
363
+ const resolved = resolveWorkspace();
364
+ const member = resolveMemberParent(resolved.root, Object.keys(registry));
365
+ const ws = member
366
+ ? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
367
+ : resolved;
284
368
  console.log(` cwd: ${process.cwd()}`);
369
+ if (member) {
370
+ 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`);
371
+ }
285
372
  line(OK, `resolves to: ${ws.root} (via ${ws.source})`);
286
373
  // A *global* VEXP_WORKSPACE env var overrides the per-session signal
287
374
  // (CLAUDE_PROJECT_DIR) for EVERY agent session, pinning Claude *and* Codex to
@@ -388,21 +475,12 @@ export async function runDoctor() {
388
475
  // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
389
476
  // not from the daemon: the index that skipped the files may have been
390
477
  // 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 */ }
478
+ // Every connected repo has its own coverage.json; each finding carries the
479
+ // repo alias so a run at the primary sees the whole workspace.
480
+ for (const f of workspaceCoverageFindings(ws.root))
481
+ line(f.level, f.message);
398
482
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
399
483
  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
484
  const entries = Object.entries(registry);
407
485
  if (entries.length === 0) {
408
486
  line(OK, "no registered daemons (empty/absent registry)");
@@ -540,6 +618,32 @@ export async function runDoctor() {
540
618
  console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
541
619
  }
542
620
  }
621
+ // 5a-bis) WSL: agents on the Windows side read Windows-side configs, which
622
+ // nothing running in WSL can see or write. Say so, and for Windsurf hand
623
+ // over the exact entry (field report, 2026-09-01: a user built the bridge by
624
+ // hand and found no documentation).
625
+ if (isWsl()) {
626
+ console.log(chalk.bold("\nWSL"));
627
+ const distro = process.env.WSL_DISTRO_NAME ?? "?";
628
+ line(OK, `running inside WSL (${distro}) — editors and apps on the Windows side read their own configs there; vexp writes only WSL-side files`);
629
+ const windsurfHere = fs.existsSync(path.join(ws.root, ".windsurf")) || fs.existsSync(path.join(ws.root, ".devin"));
630
+ if (windsurfHere) {
631
+ let bin = "vexp-core";
632
+ try {
633
+ const { getBinaryPath } = await import("./binary.js");
634
+ bin = getBinaryPath();
635
+ }
636
+ catch { /* note still useful without the exact binary path */ }
637
+ const bundle = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "mcp", "mcp-server.cjs");
638
+ line(WARN, `Windsurf/Devin Desktop on Windows will not read ${windsurfGlobalMcpPath()} (WSL home). Windows-side entry to paste:`);
639
+ for (const l of windsurfWslBridgeNote(ws.root, bin, fs.existsSync(bundle) ? bundle : undefined).split("\n")) {
640
+ console.log(chalk.dim(` ${l}`));
641
+ }
642
+ }
643
+ else {
644
+ 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
+ }
646
+ }
543
647
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
544
648
  // just check presence. A shell-form command that word-splits on a project
545
649
  // path containing a space fails non-blocking on every call: the guard never
@@ -792,15 +896,18 @@ export async function runDoctor() {
792
896
  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
897
  }
794
898
  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
899
+ // Run it, through the same shell Codex would use — cmd.exe on
900
+ // Windows (COMSPEC) with the line wrapped the way Codex wraps it, sh
901
+ // elsewhere (codexHookSpawnSpec). The script bakes an absolute path
797
902
  // to the vexp binary and exits 0 when that path is not executable, so
798
903
  // a stale or wrong-profile path leaves NO trace anywhere: no
799
904
  // orientation, no error, forever. A Windows user found exactly that
800
905
  // by reading the generated file.
801
906
  const isWin = process.platform === "win32";
802
907
  const cmdLine = (isWin ? hook.commandWindows : hook.command);
803
- const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
908
+ const spec = codexHookSpawnSpec(cmdLine);
909
+ const r = spawnSync(spec.file, spec.args, {
910
+ windowsVerbatimArguments: spec.windowsVerbatimArguments,
804
911
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
805
912
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
806
913
  timeout: 10000,
@@ -901,7 +1008,19 @@ export async function runDoctor() {
901
1008
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
902
1009
  try {
903
1010
  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)"}`);
1011
+ const alive = isAlive(rec.pid);
1012
+ line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
1013
+ // The supervisor is replaced on version mismatch only when a vexp command
1014
+ // next runs; until then every HTTP client (Codex http transport, a
1015
+ // `serverUrl` entry) talks to the OLD build — which advertises the old
1016
+ // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
1017
+ // saw two vexp servers with different tool counts and could not tell why.
1018
+ 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`);
1020
+ }
1021
+ 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}`);
1023
+ }
905
1024
  }
906
1025
  catch {
907
1026
  line(OK, "not running (no mcp.pid)");
@@ -916,4 +1035,8 @@ export async function runDoctor() {
916
1035
  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
1036
  }
918
1037
  console.log("");
1038
+ // Scripts and CI read the exit code, not the colors: any FAIL exits 1.
1039
+ // Warnings alone stay 0 — advice, not breakage (field request, 2026-09-02).
1040
+ if (fails > 0)
1041
+ process.exitCode = 1;
919
1042
  }
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,11 +183,25 @@ 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))