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/agent-config.js +323 -19
- package/dist/cli.js +11 -0
- package/dist/doctor.js +144 -21
- package/dist/serve.js +41 -0
- package/mcp/mcp-server.cjs +28 -28
- package/package.json +6 -6
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
|
-
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
796
|
-
// Windows (COMSPEC)
|
|
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
|
|
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
|
-
|
|
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))
|