vexp-cli 3.0.1 → 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/cli.js CHANGED
@@ -14,6 +14,7 @@ import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocke
14
14
  import { checkForUpdate } from "./update-check.js";
15
15
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
16
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
17
+ import { isWsl, windsurfWslBridgeNote } from "./agent-config.js";
17
18
  import { runServe } from "./serve.js";
18
19
  import { runDoctor } from "./doctor.js";
19
20
  import { socketPathFor } from "./socket-path.js";
@@ -42,6 +43,11 @@ if (isTraceEnabled()) {
42
43
  const AUTOSTART_SKIP = new Set([
43
44
  "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
44
45
  "serve", "autostart", "use", "doctor",
46
+ // Lifecycle commands: `vexp daemons` LISTS daemons and `vexp stop` ENDS
47
+ // one — spawning a daemon for the current directory first made `stop`
48
+ // followed by `daemons` bring the stopped daemon straight back
49
+ // (3.1 e2e, 2026-08).
50
+ "daemons", "stop",
45
51
  ]);
46
52
  program.hook("preAction", async (_thisCmd, actionCmd) => {
47
53
  // One-shot config migration for the multi-session fix — de-pins a legacy
@@ -456,7 +462,7 @@ program
456
462
  });
457
463
  program
458
464
  .command("search <query>")
459
- .description("Exhaustive index search: every matching node (code and docs), no top-K. Built for rename sweeps and zero-reference audits")
465
+ .description("Exhaustive index search: every symbol matching the query (code and docs) plus every line in indexed symbol bodies that references it, no top-K. Built for rename sweeps and zero-reference audits")
460
466
  .option("--substring", "Substring match (LIKE) instead of token search — partial identifiers, punctuation")
461
467
  .option("--files-only", "Print only the distinct file paths")
462
468
  .option("--json", "Machine-readable JSON with a stable shape")
@@ -796,6 +802,16 @@ program
796
802
  console.log(chalk.dim(` ${mcpFile}`));
797
803
  }
798
804
  }
805
+ // Windsurf/Devin Desktop on Windows with the project in WSL: Cascade
806
+ // starts its MCP servers on the Windows side and reads the Windows
807
+ // profile, which nothing in WSL can write. Hand over the exact entry
808
+ // instead of letting the user discover the boundary the hard way.
809
+ if (isWsl() && result.agents.some((a) => a.agent === "Windsurf")) {
810
+ console.log("");
811
+ for (const l of windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath).split("\n")) {
812
+ console.log(chalk.yellow(` ${l}`));
813
+ }
814
+ }
799
815
  // Files we refused to touch because they would not parse. This
800
816
  // used to be a stderr line mid-spinner: it scrolled away, and a
801
817
  // skipped settings.json silently cost the whole hook (field
@@ -828,7 +844,15 @@ program
828
844
  // the daemon already cold-starts on the first `vexp` invocation, so this is
829
845
  // purely a convenience for post-reboot warm-up. Skip entirely when the user
830
846
  // opted out via env or when stdin is not a TTY (non-interactive install).
831
- if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
847
+ //
848
+ // A dry run must not reach this step at all: the prompt was asked even
849
+ // under --dry-run and answering Yes rewrote the Startup-folder .vbs while
850
+ // the summary still claimed nothing was written (field report, Peiyuan,
851
+ // 3.0.1 on Windows). Persistence is a write like any other.
852
+ if (opts.dryRun) {
853
+ console.log(chalk.dim(" --dry-run: would ask about login autostart (writes a Startup entry only if you accept)."));
854
+ }
855
+ else if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
832
856
  // Explicit opt-out — do nothing.
833
857
  }
834
858
  else if (!process.stdin.isTTY) {
@@ -1108,6 +1132,9 @@ program
1108
1132
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1109
1133
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1110
1134
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1135
+ if (limits.allTools) {
1136
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1137
+ }
1111
1138
  if (limits.renewsAt) {
1112
1139
  console.log(ltd
1113
1140
  ? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
@@ -1749,6 +1776,9 @@ async function executeCommand(label, rl, outCtl) {
1749
1776
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1750
1777
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1751
1778
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1779
+ if (limits.allTools) {
1780
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1781
+ }
1752
1782
  if (limits.renewsAt) {
1753
1783
  console.log(ltd
1754
1784
  ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
package/dist/doctor.js CHANGED
@@ -5,6 +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, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath } from "./agent-config.js";
9
+ import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
10
+ import { CLI_VERSION } from "./version.js";
8
11
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
9
12
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
10
13
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -23,7 +26,8 @@ function vexpHome() {
23
26
  function discoverWorkspaceRoot(start) {
24
27
  for (const test of [
25
28
  (d) => fs.existsSync(path.join(d, ".vexp", "manifest.json")) || fs.existsSync(path.join(d, ".vexp", "index.db")),
26
- (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()),
27
31
  (d) => fs.existsSync(path.join(d, ".git")),
28
32
  ]) {
29
33
  let cur = start;
@@ -191,8 +195,159 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
191
195
  ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
192
196
  };
193
197
  }
198
+ /**
199
+ * The size-skip verdict, from `.vexp/coverage.json` (3.1 shape), as data.
200
+ *
201
+ * Files over `max_file_size_kb` are left out of the index by the walk. Until
202
+ * 3.1 the only trace was an INFO line in the daemon log: doctor, the
203
+ * `index_status` tool and coverage.json all reported a healthy index while a
204
+ * tier-4 user was missing five hand-written Dart files — 21% of the bytes of
205
+ * his lib/ — from every impact, search and pipeline answer (field report,
206
+ * 2026-08). Raising the cap only moved the cliff, so the cliff is reported
207
+ * wherever it stands. Null when nothing was skipped for size, or when the
208
+ * file predates 3.1 and has no skip keys.
209
+ */
210
+ export function coverageVerdict(cov) {
211
+ if (!cov || typeof cov !== "object")
212
+ return null;
213
+ const c = cov;
214
+ const oversized = Number(c.skipped_oversized) || 0;
215
+ if (oversized === 0)
216
+ return null;
217
+ const cap = Number(c.max_file_size_kb) || 0;
218
+ const files = Array.isArray(c.skipped_files) ? c.skipped_files : [];
219
+ const examples = files
220
+ .filter((f) => f.reason === "oversized")
221
+ .slice(0, 5)
222
+ .map((f) => `${f.path} (${Number(f.size_kb) || 0} KB)`);
223
+ const more = oversized - examples.length;
224
+ return {
225
+ level: WARN,
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` +
227
+ examples.map((e) => ` - ${e}`).join("\n") +
228
+ (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
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').`,
230
+ };
231
+ }
232
+ /**
233
+ * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
234
+ *
235
+ * A Copilot user who "set up the vexp agent" and sees neither a vexp server in
236
+ * VS Code nor vexp tools in chat has, in our experience, one of three things:
237
+ * the file is not where VS Code looks (the folder open in VS Code is not the
238
+ * one that was set up), the server entry cannot start (a bare `node` that the
239
+ * GUI-launched editor cannot find on its PATH, or a bundle path an extension
240
+ * upgrade removed), or Chat is not in Agent mode — the only mode that offers
241
+ * MCP tools. doctor can prove the first two; it can only say the third.
242
+ */
243
+ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p)) {
244
+ if (!cfg || typeof cfg !== "object")
245
+ return { level: WARN, message: ".vscode/mcp.json is present but not valid JSON — VS Code will ignore every server in it; fix the syntax and re-run 'vexp setup'" };
246
+ const servers = cfg.servers;
247
+ const vexp = servers?.vexp;
248
+ if (!vexp)
249
+ return { level: WARN, message: ".vscode/mcp.json has no 'vexp' server — run: vexp setup --agents \"GitHub Copilot\"" };
250
+ const command = typeof vexp.command === "string" ? vexp.command : "";
251
+ const args = Array.isArray(vexp.args) ? vexp.args.filter((a) => typeof a === "string") : [];
252
+ const script = args.find((a) => /\.[cm]?js$/.test(a));
253
+ if (!command)
254
+ return { level: WARN, message: ".vscode/mcp.json vexp server has no 'command' — re-run 'vexp setup'" };
255
+ if (!/[\\/]/.test(command)) {
256
+ return {
257
+ level: WARN,
258
+ message: `.vscode/mcp.json starts the vexp server with a bare '${command}' — resolved through the editor's PATH, which a VS Code launched from the Dock/Start menu usually lacks (symptom: 'spawn ${command} ENOENT' in Output › MCP: vexp, no vexp tools in chat).\n` +
259
+ ` re-run 'vexp setup' — 3.1 pins the absolute node path.`,
260
+ };
261
+ }
262
+ if (!exists(command))
263
+ return { level: WARN, message: `.vscode/mcp.json vexp command does not exist: ${command} — re-run 'vexp setup' to repin it` };
264
+ if (script && !exists(script))
265
+ return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
266
+ const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
267
+ if (pinned && path.resolve(pinned).toLowerCase() !== path.resolve(wsRoot).toLowerCase()) {
268
+ return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
269
+ }
270
+ return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
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
+ }
194
343
  export async function runDoctor() {
195
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 */ }
196
351
  let warns = 0;
197
352
  let fails = 0;
198
353
  const line = (status, msg) => {
@@ -205,8 +360,15 @@ export async function runDoctor() {
205
360
  console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
206
361
  // 1) Workspace resolution for the current directory.
207
362
  console.log(chalk.bold("Workspace targeting"));
208
- 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;
209
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
+ }
210
372
  line(OK, `resolves to: ${ws.root} (via ${ws.source})`);
211
373
  // A *global* VEXP_WORKSPACE env var overrides the per-session signal
212
374
  // (CLAUDE_PROJECT_DIR) for EVERY agent session, pinning Claude *and* Codex to
@@ -310,14 +472,15 @@ export async function runDoctor() {
310
472
  }
311
473
  }
312
474
  }
475
+ // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
476
+ // not from the daemon: the index that skipped the files may have been
477
+ // built by a daemon that is no longer running.
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);
313
482
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
314
483
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
315
- const regPath = path.join(home, ".vexp", "daemons.json");
316
- let registry = {};
317
- try {
318
- registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
319
- }
320
- catch { /* absent */ }
321
484
  const entries = Object.entries(registry);
322
485
  if (entries.length === 0) {
323
486
  line(OK, "no registered daemons (empty/absent registry)");
@@ -434,6 +597,53 @@ export async function runDoctor() {
434
597
  catch {
435
598
  line(OK, "no ~/.claude.json");
436
599
  }
600
+ // 5a) GitHub Copilot — VS Code reads MCP servers from <folder>/.vscode/mcp.json.
601
+ console.log(chalk.bold("\nGitHub Copilot / VS Code (.vscode/mcp.json)"));
602
+ {
603
+ const mcpPath = path.join(ws.root, ".vscode", "mcp.json");
604
+ if (!fs.existsSync(mcpPath)) {
605
+ line(OK, "no .vscode/mcp.json (Copilot MCP not configured in this folder — 'vexp setup --agents \"GitHub Copilot\"' writes it)");
606
+ }
607
+ else {
608
+ let cfg = null;
609
+ try {
610
+ cfg = parseJsonc(fs.readFileSync(mcpPath, "utf-8"));
611
+ }
612
+ catch {
613
+ cfg = null;
614
+ }
615
+ const v = vsCodeMcpVerdict(cfg, ws.root);
616
+ line(v.level, v.message);
617
+ console.log(chalk.dim(" VS Code shows it under Extensions → MCP SERVERS - INSTALLED; vexp tools appear only in Copilot Chat AGENT mode (tools picker → 'MCP Server: vexp')."));
618
+ console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
619
+ }
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
+ }
437
647
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
438
648
  // just check presence. A shell-form command that word-splits on a project
439
649
  // path containing a space fails non-blocking on every call: the guard never
@@ -686,15 +896,18 @@ export async function runDoctor() {
686
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.");
687
897
  }
688
898
  else {
689
- // Run it, through the same shell Codex would use: cmd.exe on
690
- // 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
691
902
  // to the vexp binary and exits 0 when that path is not executable, so
692
903
  // a stale or wrong-profile path leaves NO trace anywhere: no
693
904
  // orientation, no error, forever. A Windows user found exactly that
694
905
  // by reading the generated file.
695
906
  const isWin = process.platform === "win32";
696
907
  const cmdLine = (isWin ? hook.commandWindows : hook.command);
697
- 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,
698
911
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
699
912
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
700
913
  timeout: 10000,
@@ -795,7 +1008,19 @@ export async function runDoctor() {
795
1008
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
796
1009
  try {
797
1010
  const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
798
- 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
+ }
799
1024
  }
800
1025
  catch {
801
1026
  line(OK, "not running (no mcp.pid)");
@@ -810,4 +1035,8 @@ export async function runDoctor() {
810
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."));
811
1036
  }
812
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;
813
1042
  }
package/dist/license.js CHANGED
@@ -206,10 +206,10 @@ export async function tryOnlineRefresh(longJwt) {
206
206
  // Only save if the freshToken itself verifies locally
207
207
  if (verifyAndDecode(data.freshToken)) {
208
208
  saveFreshToken(data.freshToken);
209
- // The server re-issued a 30-day long token (entitlement changed:
210
- // AppSumo tier up/downgrade, or a Stripe resub migration). Overwrite
211
- // the on-disk long JWT so the new entitlement survives even fully
212
- // offline and the previous one stops working — without the user
209
+ // The server re-issued the 30-day long token (rolled forward on every
210
+ // refresh since vexp-web 3.1; also on entitlement change). Overwrite
211
+ // the on-disk long JWT so the current entitlement survives even fully
212
+ // offline and a superseded one stops working — without the user
213
213
  // re-pasting a key. Only persist if it verifies locally.
214
214
  if (data.newLongToken && verifyAndDecode(data.newLongToken)) {
215
215
  try {
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))