vexp-cli 3.1.0 → 3.1.2
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 +328 -20
- package/dist/cli.js +45 -3
- package/dist/doctor.js +192 -24
- package/dist/mcp-supervisor.js +67 -7
- package/dist/serve.js +81 -1
- package/mcp/mcp-server.cjs +37 -35
- 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;
|
|
@@ -204,7 +207,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
|
|
|
204
207
|
* wherever it stands. Null when nothing was skipped for size, or when the
|
|
205
208
|
* file predates 3.1 and has no skip keys.
|
|
206
209
|
*/
|
|
207
|
-
export function coverageVerdict(cov) {
|
|
210
|
+
export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
|
|
208
211
|
if (!cov || typeof cov !== "object")
|
|
209
212
|
return null;
|
|
210
213
|
const c = cov;
|
|
@@ -222,8 +225,13 @@ export function coverageVerdict(cov) {
|
|
|
222
225
|
level: WARN,
|
|
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
|
-
(more > 0 ? `\n … +${more} more
|
|
226
|
-
|
|
228
|
+
(more > 0 ? `\n … +${more} more` : "") +
|
|
229
|
+
// The path, not the name. coverage.json lives at the WORKSPACE root;
|
|
230
|
+
// a tester on a monorepo looked for it beside the package he was
|
|
231
|
+
// working in, found nothing, and concluded the feature had not
|
|
232
|
+
// shipped (tier-4 field report, 2026-09-05).
|
|
233
|
+
`\n full list: ${coveragePath}` +
|
|
234
|
+
`\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
235
|
};
|
|
228
236
|
}
|
|
229
237
|
/**
|
|
@@ -266,8 +274,85 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
|
|
|
266
274
|
}
|
|
267
275
|
return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
|
|
268
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* How doctor runs a Codex `UserPromptSubmit` hook line: the way Codex does.
|
|
279
|
+
* On Windows Codex hands `cmd.exe /C` the WHOLE line wrapped in one more pair
|
|
280
|
+
* of quotes, verbatim (codex-rs/hooks engine/command_runner: `raw_arg`), and
|
|
281
|
+
* cmd strips that outer pair — which is why the installer may quote the
|
|
282
|
+
* script path. Passing the line as a normal argument instead lets Node escape
|
|
283
|
+
* the inner quotes, and cmd then looks for a program literally named
|
|
284
|
+
* `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
|
|
285
|
+
* failed doctor's probe and passed once the user removed the quotes).
|
|
286
|
+
*/
|
|
287
|
+
export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
|
|
288
|
+
if (platform === "win32") {
|
|
289
|
+
return { file: comspec || "cmd.exe", args: ["/C", `"${cmdLine}"`], windowsVerbatimArguments: true };
|
|
290
|
+
}
|
|
291
|
+
return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
|
|
292
|
+
}
|
|
293
|
+
function samePath(a, b) {
|
|
294
|
+
// The daemon registry lower-cases Windows roots; compare accordingly.
|
|
295
|
+
return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A connected repo of a multi-repo workspace has no daemon of its own: the
|
|
299
|
+
* parent's daemon serves it. Doctor run inside such a repo used to treat it as
|
|
300
|
+
* a standalone workspace — "daemon NOT reachable", "NOT registered" — while
|
|
301
|
+
* `vexp search` from the same directory answered fine (field report,
|
|
302
|
+
* 2026-09-01, nine repos). The link is `.vexp/parent_workspace.json` in the
|
|
303
|
+
* member; when that file is missing, any registered workspace whose
|
|
304
|
+
* workspace.json lists this directory is the parent.
|
|
305
|
+
*/
|
|
306
|
+
export function resolveMemberParent(root, registryRoots) {
|
|
307
|
+
const link = resolveParentWorkspace(root);
|
|
308
|
+
if (link) {
|
|
309
|
+
const alias = listWorkspaceRepos(link.parentRoot).find((r) => samePath(r.resolvedPath, root))?.alias
|
|
310
|
+
?? path.basename(root);
|
|
311
|
+
return { parentRoot: link.parentRoot, alias, via: ".vexp/parent_workspace.json" };
|
|
312
|
+
}
|
|
313
|
+
for (const reg of registryRoots) {
|
|
314
|
+
if (samePath(reg, root))
|
|
315
|
+
continue;
|
|
316
|
+
const hit = listWorkspaceRepos(reg).find((r) => !r.isPrimary && samePath(r.resolvedPath, root));
|
|
317
|
+
if (hit)
|
|
318
|
+
return { parentRoot: reg, alias: hit.alias, via: `${path.join(reg, ".vexp", "workspace.json")} (daemon registry)` };
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Coverage verdicts for the workspace at `root` AND every repo connected to
|
|
324
|
+
* it, each tagged with its alias. Each repo's indexer writes its own
|
|
325
|
+
* `.vexp/coverage.json`; reading only the primary's hid three oversized files
|
|
326
|
+
* in a connected repo from a doctor run at the primary (same report).
|
|
327
|
+
*/
|
|
328
|
+
export function workspaceCoverageFindings(root) {
|
|
329
|
+
const repos = listWorkspaceRepos(root);
|
|
330
|
+
const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
|
|
331
|
+
if (!targets.some((t) => t.primary))
|
|
332
|
+
targets.unshift({ alias: path.basename(root), dir: root, primary: true });
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const t of targets) {
|
|
335
|
+
let cov;
|
|
336
|
+
try {
|
|
337
|
+
cov = JSON.parse(fs.readFileSync(path.join(t.dir, ".vexp", "coverage.json"), "utf-8"));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
continue; /* never indexed, or an index older than 2.7 */
|
|
341
|
+
}
|
|
342
|
+
const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
|
|
343
|
+
if (v)
|
|
344
|
+
out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
269
348
|
export async function runDoctor() {
|
|
270
349
|
const home = vexpHome();
|
|
350
|
+
const regPath = path.join(home, ".vexp", "daemons.json");
|
|
351
|
+
let registry = {};
|
|
352
|
+
try {
|
|
353
|
+
registry = JSON.parse(fs.readFileSync(regPath, "utf-8"));
|
|
354
|
+
}
|
|
355
|
+
catch { /* absent */ }
|
|
271
356
|
let warns = 0;
|
|
272
357
|
let fails = 0;
|
|
273
358
|
const line = (status, msg) => {
|
|
@@ -280,8 +365,15 @@ export async function runDoctor() {
|
|
|
280
365
|
console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
|
|
281
366
|
// 1) Workspace resolution for the current directory.
|
|
282
367
|
console.log(chalk.bold("Workspace targeting"));
|
|
283
|
-
const
|
|
368
|
+
const resolved = resolveWorkspace();
|
|
369
|
+
const member = resolveMemberParent(resolved.root, Object.keys(registry));
|
|
370
|
+
const ws = member
|
|
371
|
+
? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
|
|
372
|
+
: resolved;
|
|
284
373
|
console.log(` cwd: ${process.cwd()}`);
|
|
374
|
+
if (member) {
|
|
375
|
+
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`);
|
|
376
|
+
}
|
|
285
377
|
line(OK, `resolves to: ${ws.root} (via ${ws.source})`);
|
|
286
378
|
// A *global* VEXP_WORKSPACE env var overrides the per-session signal
|
|
287
379
|
// (CLAUDE_PROJECT_DIR) for EVERY agent session, pinning Claude *and* Codex to
|
|
@@ -307,9 +399,18 @@ export async function runDoctor() {
|
|
|
307
399
|
if (live) {
|
|
308
400
|
const st = await queryDaemon(sock, "index_status");
|
|
309
401
|
if (st) {
|
|
402
|
+
// A daemon that is still loading its model answers "rule", which reads
|
|
403
|
+
// as a downgrade for the ~20s it takes to settle. The daemon already
|
|
404
|
+
// says which of the two it is; doctor was printing the label and
|
|
405
|
+
// dropping the reason (tier-4 field report, 2026-09-05).
|
|
406
|
+
const loading = st.compressor !== "llm"
|
|
407
|
+
&& typeof st.llm_inactive_reason === "string"
|
|
408
|
+
&& /still loading|finishing its startup/i.test(st.llm_inactive_reason);
|
|
310
409
|
const comp = st.compressor === "llm"
|
|
311
410
|
? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
|
|
312
|
-
:
|
|
411
|
+
: loading
|
|
412
|
+
? "loading (the model is still starting — this is not the rule compressor settling in)"
|
|
413
|
+
: st.compressor ?? "unknown (older daemon)";
|
|
313
414
|
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
314
415
|
if (st.llm_configured_but_inactive === true) {
|
|
315
416
|
// The daemon knows WHY, and doctor was throwing it away: it printed
|
|
@@ -388,21 +489,12 @@ export async function runDoctor() {
|
|
|
388
489
|
// 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
|
|
389
490
|
// not from the daemon: the index that skipped the files may have been
|
|
390
491
|
// 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 */ }
|
|
492
|
+
// Every connected repo has its own coverage.json; each finding carries the
|
|
493
|
+
// repo alias so a run at the primary sees the whole workspace.
|
|
494
|
+
for (const f of workspaceCoverageFindings(ws.root))
|
|
495
|
+
line(f.level, f.message);
|
|
398
496
|
// 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
|
|
399
497
|
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
498
|
const entries = Object.entries(registry);
|
|
407
499
|
if (entries.length === 0) {
|
|
408
500
|
line(OK, "no registered daemons (empty/absent registry)");
|
|
@@ -540,6 +632,32 @@ export async function runDoctor() {
|
|
|
540
632
|
console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
|
|
541
633
|
}
|
|
542
634
|
}
|
|
635
|
+
// 5a-bis) WSL: agents on the Windows side read Windows-side configs, which
|
|
636
|
+
// nothing running in WSL can see or write. Say so, and for Windsurf hand
|
|
637
|
+
// over the exact entry (field report, 2026-09-01: a user built the bridge by
|
|
638
|
+
// hand and found no documentation).
|
|
639
|
+
if (isWsl()) {
|
|
640
|
+
console.log(chalk.bold("\nWSL"));
|
|
641
|
+
const distro = process.env.WSL_DISTRO_NAME ?? "?";
|
|
642
|
+
line(OK, `running inside WSL (${distro}) — editors and apps on the Windows side read their own configs there; vexp writes only WSL-side files`);
|
|
643
|
+
const windsurfHere = fs.existsSync(path.join(ws.root, ".windsurf")) || fs.existsSync(path.join(ws.root, ".devin"));
|
|
644
|
+
if (windsurfHere) {
|
|
645
|
+
let bin = "vexp-core";
|
|
646
|
+
try {
|
|
647
|
+
const { getBinaryPath } = await import("./binary.js");
|
|
648
|
+
bin = getBinaryPath();
|
|
649
|
+
}
|
|
650
|
+
catch { /* note still useful without the exact binary path */ }
|
|
651
|
+
const bundle = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "mcp", "mcp-server.cjs");
|
|
652
|
+
line(WARN, `Windsurf/Devin Desktop on Windows will not read ${windsurfGlobalMcpPath()} (WSL home). Windows-side entry to paste:`);
|
|
653
|
+
for (const l of windsurfWslBridgeNote(ws.root, bin, fs.existsSync(bundle) ? bundle : undefined).split("\n")) {
|
|
654
|
+
console.log(chalk.dim(` ${l}`));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
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
|
+
}
|
|
660
|
+
}
|
|
543
661
|
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
544
662
|
// just check presence. A shell-form command that word-splits on a project
|
|
545
663
|
// path containing a space fails non-blocking on every call: the guard never
|
|
@@ -792,15 +910,18 @@ export async function runDoctor() {
|
|
|
792
910
|
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
911
|
}
|
|
794
912
|
else {
|
|
795
|
-
// Run it, through the same shell Codex would use
|
|
796
|
-
// Windows (COMSPEC)
|
|
913
|
+
// Run it, through the same shell Codex would use — cmd.exe on
|
|
914
|
+
// Windows (COMSPEC) with the line wrapped the way Codex wraps it, sh
|
|
915
|
+
// elsewhere (codexHookSpawnSpec). The script bakes an absolute path
|
|
797
916
|
// to the vexp binary and exits 0 when that path is not executable, so
|
|
798
917
|
// a stale or wrong-profile path leaves NO trace anywhere: no
|
|
799
918
|
// orientation, no error, forever. A Windows user found exactly that
|
|
800
919
|
// by reading the generated file.
|
|
801
920
|
const isWin = process.platform === "win32";
|
|
802
921
|
const cmdLine = (isWin ? hook.commandWindows : hook.command);
|
|
803
|
-
const
|
|
922
|
+
const spec = codexHookSpawnSpec(cmdLine);
|
|
923
|
+
const r = spawnSync(spec.file, spec.args, {
|
|
924
|
+
windowsVerbatimArguments: spec.windowsVerbatimArguments,
|
|
804
925
|
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
805
926
|
input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
|
|
806
927
|
timeout: 10000,
|
|
@@ -897,11 +1018,54 @@ export async function runDoctor() {
|
|
|
897
1018
|
}
|
|
898
1019
|
}
|
|
899
1020
|
}
|
|
1021
|
+
// A supervisor from an older install respawning the current bundle. It
|
|
1022
|
+
// keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
|
|
1023
|
+
// stop it: the old process runs the old comparison. The only fix is to end
|
|
1024
|
+
// it, so doctor has to name it — with its pid, which the record now
|
|
1025
|
+
// carries. A tester spent a night on a :7821 server that came back with a
|
|
1026
|
+
// new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
|
|
1027
|
+
// was not installed anywhere: the LABEL came from a `vexp serve` resident
|
|
1028
|
+
// since before the upgrade, holding its own version in memory
|
|
1029
|
+
// (field report, 2026-09-06).
|
|
1030
|
+
const staleSupervisorLine = (rec) => {
|
|
1031
|
+
const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
|
|
1032
|
+
if (!by || by === CLI_VERSION)
|
|
1033
|
+
return;
|
|
1034
|
+
const pid = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
|
|
1035
|
+
const stillUp = pid !== undefined && isAlive(pid);
|
|
1036
|
+
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"})` : "") +
|
|
1039
|
+
`\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
|
|
1040
|
+
// Ending it is the whole remediation: login autostart (or the next
|
|
1041
|
+
// `vexp` command) starts a fresh supervisor, which reads the current
|
|
1042
|
+
// package.json and serves the current build. The tester who did this
|
|
1043
|
+
// had a correct server one second later without running anything
|
|
1044
|
+
// else — and the `vexp setup` this line used to recommend blocks on
|
|
1045
|
+
// an interactive prompt, which for the reader of a doctor line is a
|
|
1046
|
+
// 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` : ""));
|
|
1048
|
+
};
|
|
900
1049
|
// 6) HTTP MCP supervisor.
|
|
901
1050
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
902
1051
|
try {
|
|
903
1052
|
const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
|
|
904
|
-
|
|
1053
|
+
const alive = isAlive(rec.pid);
|
|
1054
|
+
line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
|
|
1055
|
+
if (alive && rec.version === CLI_VERSION)
|
|
1056
|
+
staleSupervisorLine(rec);
|
|
1057
|
+
// The supervisor is replaced on version mismatch only when a vexp command
|
|
1058
|
+
// next runs; until then every HTTP client (Codex http transport, a
|
|
1059
|
+
// `serverUrl` entry) talks to the OLD build — which advertises the old
|
|
1060
|
+
// tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
|
|
1061
|
+
// saw two vexp servers with different tool counts and could not tell why.
|
|
1062
|
+
if (alive && rec.version && rec.version !== CLI_VERSION) {
|
|
1063
|
+
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' in the WORKSPACE ROOT (the directory holding .vexp/) and the current CLI takes it over`);
|
|
1064
|
+
staleSupervisorLine(rec);
|
|
1065
|
+
}
|
|
1066
|
+
else if (alive && !rec.version) {
|
|
1067
|
+
line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup' in the WORKSPACE ROOT so the current CLI replaces it and HTTP clients get v${CLI_VERSION}`);
|
|
1068
|
+
}
|
|
905
1069
|
}
|
|
906
1070
|
catch {
|
|
907
1071
|
line(OK, "not running (no mcp.pid)");
|
|
@@ -916,4 +1080,8 @@ export async function runDoctor() {
|
|
|
916
1080
|
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
1081
|
}
|
|
918
1082
|
console.log("");
|
|
1083
|
+
// Scripts and CI read the exit code, not the colors: any FAIL exits 1.
|
|
1084
|
+
// Warnings alone stay 0 — advice, not breakage (field request, 2026-09-02).
|
|
1085
|
+
if (fails > 0)
|
|
1086
|
+
process.exitCode = 1;
|
|
919
1087
|
}
|
package/dist/mcp-supervisor.js
CHANGED
|
@@ -33,6 +33,8 @@ export function readPidRecord() {
|
|
|
33
33
|
// pass (measured 60.7s replacement period). The reuse fast path
|
|
34
34
|
// is only as good as the record it reads.
|
|
35
35
|
version: typeof rec.version === "string" ? rec.version : undefined,
|
|
36
|
+
spawnedBy: typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined,
|
|
37
|
+
spawnedByPid: typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -97,6 +99,48 @@ export function resolveOrGenerateMcpToken() {
|
|
|
97
99
|
* The MCP server is deliberately workspace-agnostic: clients disambiguate
|
|
98
100
|
* via URL path /ws/<workspaceHash>/mcp. No VEXP_WORKSPACE env is passed.
|
|
99
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* Is the running server's version strictly older than ours?
|
|
104
|
+
*
|
|
105
|
+
* A record with no version predates version tracking (< 2.4) and counts as
|
|
106
|
+
* older. An unparseable version counts as older too: replacing it once is
|
|
107
|
+
* recoverable, whereas deferring to it forever is what the field report
|
|
108
|
+
* showed. A NEWER server is left alone — this process is the stale one.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* The version of the MCP bundle at `mcpPath`, read from the package.json that
|
|
112
|
+
* ships it — the build a client will actually be served, as opposed to the
|
|
113
|
+
* version of whatever process happens to be spawning it.
|
|
114
|
+
*/
|
|
115
|
+
export function mcpBundleVersion(mcpPath) {
|
|
116
|
+
const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(pkg, "utf-8"));
|
|
119
|
+
if (typeof parsed.version === "string")
|
|
120
|
+
return parsed.version;
|
|
121
|
+
}
|
|
122
|
+
catch { /* not readable: fall back to the caller's own version */ }
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
export function isOlderVersion(recorded, mine) {
|
|
126
|
+
if (!recorded)
|
|
127
|
+
return true;
|
|
128
|
+
if (recorded === mine)
|
|
129
|
+
return false;
|
|
130
|
+
const parts = (v) => {
|
|
131
|
+
const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
|
|
132
|
+
return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
|
|
133
|
+
};
|
|
134
|
+
const a = parts(recorded);
|
|
135
|
+
const b = parts(mine);
|
|
136
|
+
if (!a || !b)
|
|
137
|
+
return true;
|
|
138
|
+
for (let i = 0; i < 3; i++) {
|
|
139
|
+
if (a[i] !== b[i])
|
|
140
|
+
return a[i] < b[i];
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
100
144
|
export async function ensureMcpHttpServer(opts = {}) {
|
|
101
145
|
const port = opts.port ?? DEFAULT_PORT;
|
|
102
146
|
const owner = opts.owner ?? "unknown";
|
|
@@ -104,15 +148,23 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
104
148
|
const mcpPath = getMcpServerPath();
|
|
105
149
|
if (!mcpPath)
|
|
106
150
|
return null;
|
|
107
|
-
// Fast path: already alive and tracked - but only if it is
|
|
108
|
-
// Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
109
|
-
// 9 days across extension upgrades because reuse never compared
|
|
110
|
-
//
|
|
111
|
-
//
|
|
151
|
+
// Fast path: already alive and tracked - but only if it is not OLDER than
|
|
152
|
+
// us. Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
153
|
+
// 9 days across extension upgrades because reuse never compared versions.
|
|
154
|
+
// On an older server we take over: SIGTERM the old listener and spawn the
|
|
155
|
+
// current build.
|
|
156
|
+
//
|
|
157
|
+
// The comparison is ORDERED, not an equality check. Equality made takeover
|
|
158
|
+
// symmetric, so two installs of different versions each saw the other as
|
|
159
|
+
// "not mine" and reclaimed the port from it — and `vexp serve` re-runs this
|
|
160
|
+
// every 60s, so a supervisor left behind by an older install resurrected
|
|
161
|
+
// its own build indefinitely. A tester watched a v3.0.1 server come back
|
|
162
|
+
// after being replaced by v3.1.1, with a fresh pid (tier-4 field report,
|
|
163
|
+
// 2026-09-05). An older build now stands down and reuses what is there.
|
|
112
164
|
{
|
|
113
165
|
const existing = readPidRecord();
|
|
114
166
|
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
115
|
-
if (existing.version
|
|
167
|
+
if (!isOlderVersion(existing.version, CLI_VERSION)) {
|
|
116
168
|
return { pid: existing.pid, port, started: false };
|
|
117
169
|
}
|
|
118
170
|
try {
|
|
@@ -184,7 +236,15 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
184
236
|
const pid = child.pid;
|
|
185
237
|
if (!pid)
|
|
186
238
|
return null;
|
|
187
|
-
writePidRecord({
|
|
239
|
+
writePidRecord({
|
|
240
|
+
pid,
|
|
241
|
+
port,
|
|
242
|
+
startedAt: Date.now(),
|
|
243
|
+
owner,
|
|
244
|
+
version: mcpBundleVersion(mcpPath) ?? CLI_VERSION,
|
|
245
|
+
spawnedBy: CLI_VERSION,
|
|
246
|
+
spawnedByPid: process.pid,
|
|
247
|
+
});
|
|
188
248
|
// Wait for the child to actually bind the port BEFORE releasing the
|
|
189
249
|
// lock. Without this, a second caller arriving right after release
|
|
190
250
|
// sees pidfile+live-pid but port-not-yet-bound → falsely concludes
|
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,17 +183,70 @@ 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))
|
|
167
208
|
live[ws] = sock;
|
|
168
209
|
}
|
|
169
|
-
|
|
210
|
+
// A row this supervisor did not resurrect is not thereby dead.
|
|
211
|
+
//
|
|
212
|
+
// `live` used to become the WHOLE new registry, so every row the plan
|
|
213
|
+
// skipped — another supervisor's daemon, a linked worktree, a workspace
|
|
214
|
+
// whose manifest had moved — was deleted while its daemon was still
|
|
215
|
+
// running and answering on its socket. The health loop re-runs this every
|
|
216
|
+
// 60s, so the deletion was permanent and self-renewing: on a tester's
|
|
217
|
+
// machine two daemons that had been serving a pre-upgrade build for five
|
|
218
|
+
// days disappeared from `vexp daemons`, from `index_status`'s "other live
|
|
219
|
+
// daemons" and from the concurrency fence the moment a supervisor
|
|
220
|
+
// restarted, leaving a three-daemon box reporting a clean single-workspace
|
|
221
|
+
// install (field report, 2026-09-07).
|
|
222
|
+
//
|
|
223
|
+
// Liveness decides what stays in the registry, never ownership. The fence
|
|
224
|
+
// counts these rows, so dropping a live daemon also made the cap evadable
|
|
225
|
+
// by restarting a supervisor.
|
|
226
|
+
writeRegistry(await survivingRegistryRows(reg, live, isSocketAlive, appendLog));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The rows that stay in `~/.vexp/daemons.json` after a resurrection pass:
|
|
230
|
+
* everything this supervisor brought up, PLUS every other row whose daemon is
|
|
231
|
+
* still answering. Only a row that is neither is dropped.
|
|
232
|
+
*
|
|
233
|
+
* Separated out because the rule is the whole bug: a supervisor may only
|
|
234
|
+
* report what it could not find, never delete what it does not own.
|
|
235
|
+
*/
|
|
236
|
+
export async function survivingRegistryRows(before, resurrected, alive, log = () => { }) {
|
|
237
|
+
const canonical = (ws) => (process.platform === "win32" ? ws.toLowerCase() : ws);
|
|
238
|
+
const out = { ...resurrected };
|
|
239
|
+
const kept = new Set(Object.keys(out).map(canonical));
|
|
240
|
+
for (const [ws, sock] of Object.entries(before)) {
|
|
241
|
+
if (kept.has(canonical(ws)))
|
|
242
|
+
continue;
|
|
243
|
+
if (await alive(sock)) {
|
|
244
|
+
out[ws] = sock;
|
|
245
|
+
kept.add(canonical(ws));
|
|
246
|
+
log(`registry keep: ${ws} (not ours to resurrect, but its daemon is alive)`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
170
250
|
}
|
|
171
251
|
async function startMcp() {
|
|
172
252
|
const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);
|