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/agent-config.js +442 -18
- package/dist/cli.js +32 -2
- package/dist/doctor.js +241 -12
- package/dist/license.js +4 -4
- package/dist/serve.js +41 -0
- package/mcp/mcp-server.cjs +29 -29
- package/package.json +6 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
690
|
-
// 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
|
|
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
|
|
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
|
-
|
|
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
|
|
210
|
-
//
|
|
211
|
-
// the on-disk long JWT so the
|
|
212
|
-
// offline and
|
|
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))
|