vexp-cli 3.1.1 → 3.1.3
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 +5 -1
- package/dist/cli.js +45 -5
- package/dist/doctor.js +323 -8
- package/dist/mcp-supervisor.js +103 -8
- package/dist/serve.js +40 -1
- package/dist/slow-mount.js +81 -0
- package/dist/socket-path.js +24 -0
- package/mcp/mcp-server.cjs +37 -35
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -2918,7 +2918,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
2918
2918
|
let root = {};
|
|
2919
2919
|
if (fs.existsSync(hooksJsonPath)) {
|
|
2920
2920
|
try {
|
|
2921
|
-
|
|
2921
|
+
// parseJsonc, not JSON.parse: every other config reader here is
|
|
2922
|
+
// BOM-tolerant and this one was missed. Windows PowerShell 5.1 writes
|
|
2923
|
+
// JSON with a UTF-8 BOM, and a user who edits .codex/hooks.json by hand
|
|
2924
|
+
// there would have the hook silently left unregistered.
|
|
2925
|
+
root = parseJsonc(fs.readFileSync(hooksJsonPath, "utf-8"));
|
|
2922
2926
|
}
|
|
2923
2927
|
catch {
|
|
2924
2928
|
warnUnparseable(hooksJsonPath);
|
package/dist/cli.js
CHANGED
|
@@ -66,13 +66,32 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
66
66
|
// the parent, so we boot (or reuse) the parent daemon instead of spawning
|
|
67
67
|
// a conflicting one for the child.
|
|
68
68
|
const eff = findEffectiveWorkspace();
|
|
69
|
-
|
|
69
|
+
// Say so. Bootstrapping is what replaces a stale daemon and takes over a
|
|
70
|
+
// stale MCP server on :7821, and it is silently skipped outside a
|
|
71
|
+
// configured workspace: a tester ran the command doctor had suggested from
|
|
72
|
+
// a package subdirectory of a monorepo, got no error and no takeover, and
|
|
73
|
+
// reasonably concluded the server had been replaced (tier-4 field report,
|
|
74
|
+
// 2026-09-05). Doing nothing quietly is the one outcome that cannot be
|
|
75
|
+
// told apart from success.
|
|
76
|
+
if (!eff) {
|
|
77
|
+
// `index` and `init` are the commands that CREATE a workspace, so telling
|
|
78
|
+
// them to go run `vexp setup` first is advice the command is in the middle
|
|
79
|
+
// of superseding — and it read as a contradiction to a user watching one
|
|
80
|
+
// invocation warn that no workspace exists and the next one start a daemon
|
|
81
|
+
// for it (field report, 2026-09-08). The bootstrap still does nothing here;
|
|
82
|
+
// it just does not misdirect.
|
|
83
|
+
if (actionCmd.name() === "index" || actionCmd.name() === "init")
|
|
84
|
+
return;
|
|
85
|
+
console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
|
|
86
|
+
console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
|
|
70
87
|
return;
|
|
88
|
+
}
|
|
71
89
|
let binaryPath;
|
|
72
90
|
try {
|
|
73
91
|
binaryPath = getBinaryPath();
|
|
74
92
|
}
|
|
75
|
-
catch {
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(chalk.yellow(` ⚠ vexp-core binary not resolved (${err instanceof Error ? err.message : err}) — nothing was started or updated.`));
|
|
76
95
|
return;
|
|
77
96
|
}
|
|
78
97
|
// Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
|
|
@@ -274,7 +293,12 @@ async function ensureBootstrap(workspaceRoot, binaryPath) {
|
|
|
274
293
|
try {
|
|
275
294
|
await ensureMcpHttpServer({ owner: "cli" });
|
|
276
295
|
}
|
|
277
|
-
catch {
|
|
296
|
+
catch (err) {
|
|
297
|
+
// Non-fatal, but never silent: this is the step that replaces an MCP
|
|
298
|
+
// server left behind by an older install, and skipping it leaves every
|
|
299
|
+
// MCP client on the old build.
|
|
300
|
+
console.error(chalk.dim(` ⚠ MCP HTTP server not started or taken over: ${err instanceof Error ? err.message : err}`));
|
|
301
|
+
}
|
|
278
302
|
}
|
|
279
303
|
/**
|
|
280
304
|
* Start the vexp daemon and MCP HTTP server in the background.
|
|
@@ -629,8 +653,9 @@ program
|
|
|
629
653
|
program
|
|
630
654
|
.command("doctor")
|
|
631
655
|
.description("Diagnose vexp MCP/daemon state — workspace drift, stale daemons, Codex transport, license (no daemon needed)")
|
|
632
|
-
.
|
|
633
|
-
|
|
656
|
+
.option("--report [file]", "Also write a shareable Markdown report: this diagnosis, the local tool-call ledger (counts and timings, never contents), daemon log warnings, config — secrets masked (default: .vexp/vexp-report.md)")
|
|
657
|
+
.action(async (opts) => {
|
|
658
|
+
await runDoctor({ report: opts.report });
|
|
634
659
|
});
|
|
635
660
|
program
|
|
636
661
|
.command("setup [dir]")
|
|
@@ -725,6 +750,21 @@ program
|
|
|
725
750
|
selectedNames = requested.map((n) => resolveAgentName(n));
|
|
726
751
|
console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
|
|
727
752
|
}
|
|
753
|
+
else if (!process.stdin.isTTY) {
|
|
754
|
+
// No one is at the keyboard. This command is named as a remediation
|
|
755
|
+
// in doctor's own output, and an agent — or a CI step, or a script
|
|
756
|
+
// reading that line — cannot answer a prompt: it hangs until it is
|
|
757
|
+
// killed, which is worse than failing (field report, 2026-09-07: a
|
|
758
|
+
// tester followed doctor's advice and had to abandon the command).
|
|
759
|
+
// Configure what is detected, and say that is what happened.
|
|
760
|
+
selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
|
|
761
|
+
if (selectedNames.length === 0) {
|
|
762
|
+
console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
|
|
763
|
+
console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
|
|
767
|
+
}
|
|
728
768
|
else {
|
|
729
769
|
// Interactive selection
|
|
730
770
|
console.log(chalk.bold("\n Select AI agents to configure:\n"));
|
package/dist/doctor.js
CHANGED
|
@@ -4,10 +4,12 @@ import * as path from "path";
|
|
|
4
4
|
import * as net from "net";
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
6
|
import chalk from "chalk";
|
|
7
|
-
import { socketPathFor } from "./socket-path.js";
|
|
7
|
+
import { socketPathFor, socketPathMargin } from "./socket-path.js";
|
|
8
8
|
import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath } from "./agent-config.js";
|
|
9
9
|
import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
|
|
10
10
|
import { CLI_VERSION } from "./version.js";
|
|
11
|
+
import { parentPid } from "./mcp-supervisor.js";
|
|
12
|
+
import { slowMountNotice } from "./slow-mount.js";
|
|
11
13
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
12
14
|
// Surfaces the failure modes behind the Codex drift report: stale daemons.json
|
|
13
15
|
// entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
|
|
@@ -98,6 +100,32 @@ function jwtExp(file) {
|
|
|
98
100
|
return null;
|
|
99
101
|
}
|
|
100
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* What doctor should say about the process that spawned the MCP server.
|
|
105
|
+
*
|
|
106
|
+
* Pure, and exported, because this guard has now been wrong twice in the same
|
|
107
|
+
* way. The first version compared the recorded `spawnedBy` against ours and
|
|
108
|
+
* returned on anything else — so a record written BEFORE that field existed
|
|
109
|
+
* took the same branch as a record that matches, and the disclosure was
|
|
110
|
+
* silent on the one upgrade it exists for: the first one after it shipped
|
|
111
|
+
* (field report, 2026-09-08).
|
|
112
|
+
*
|
|
113
|
+
* "I cannot tell" and "there is no mismatch" are different answers and must
|
|
114
|
+
* never share a branch again.
|
|
115
|
+
*/
|
|
116
|
+
export function supervisorVerdict(rec, cliVersion, io) {
|
|
117
|
+
const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
|
|
118
|
+
if (by === cliVersion)
|
|
119
|
+
return { kind: "ok" };
|
|
120
|
+
// The supervisor is the PARENT of the server, which is observable whatever
|
|
121
|
+
// wrote the record — and is how the field report identified it.
|
|
122
|
+
const recorded = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
|
|
123
|
+
const pid = recorded ?? (typeof rec.pid === "number" ? io.parentOf(rec.pid) : undefined);
|
|
124
|
+
if (pid === undefined) {
|
|
125
|
+
return by ? { kind: "stale", by } : { kind: "unknown" };
|
|
126
|
+
}
|
|
127
|
+
return { kind: "stale", by, pid, alive: io.alive(pid) };
|
|
128
|
+
}
|
|
101
129
|
function isAlive(pid) {
|
|
102
130
|
try {
|
|
103
131
|
process.kill(pid, 0);
|
|
@@ -207,7 +235,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
|
|
|
207
235
|
* wherever it stands. Null when nothing was skipped for size, or when the
|
|
208
236
|
* file predates 3.1 and has no skip keys.
|
|
209
237
|
*/
|
|
210
|
-
export function coverageVerdict(cov) {
|
|
238
|
+
export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
|
|
211
239
|
if (!cov || typeof cov !== "object")
|
|
212
240
|
return null;
|
|
213
241
|
const c = cov;
|
|
@@ -225,7 +253,12 @@ export function coverageVerdict(cov) {
|
|
|
225
253
|
level: WARN,
|
|
226
254
|
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
255
|
examples.map((e) => ` - ${e}`).join("\n") +
|
|
228
|
-
(more > 0 ? `\n … +${more} more
|
|
256
|
+
(more > 0 ? `\n … +${more} more` : "") +
|
|
257
|
+
// The path, not the name. coverage.json lives at the WORKSPACE root;
|
|
258
|
+
// a tester on a monorepo looked for it beside the package he was
|
|
259
|
+
// working in, found nothing, and concluded the feature had not
|
|
260
|
+
// shipped (tier-4 field report, 2026-09-05).
|
|
261
|
+
`\n full list: ${coveragePath}` +
|
|
229
262
|
`\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
263
|
};
|
|
231
264
|
}
|
|
@@ -334,13 +367,212 @@ export function workspaceCoverageFindings(root) {
|
|
|
334
367
|
catch {
|
|
335
368
|
continue; /* never indexed, or an index older than 2.7 */
|
|
336
369
|
}
|
|
337
|
-
const v = coverageVerdict(cov);
|
|
370
|
+
const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
|
|
338
371
|
if (v)
|
|
339
372
|
out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
|
|
340
373
|
}
|
|
341
374
|
return out;
|
|
342
375
|
}
|
|
343
|
-
export
|
|
376
|
+
export const SUPPORT_EMAIL = "staff@vexp.dev";
|
|
377
|
+
export const DEFAULT_REPORT_FILE = path.join(".vexp", "vexp-report.md");
|
|
378
|
+
// eslint-disable-next-line no-control-regex
|
|
379
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
380
|
+
export function stripAnsi(s) {
|
|
381
|
+
return s.replace(ANSI, "");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Mask secret-shaped values in text that leaves the machine. The Rust
|
|
385
|
+
* shield (`shield::redact_secrets`) is the reference; this is the subset a
|
|
386
|
+
* doctor report needs: known token shapes and KEY=value assignments. Never
|
|
387
|
+
* enough of the value to reconstruct it, always enough to see there was one.
|
|
388
|
+
*/
|
|
389
|
+
const SECRET_SHAPES = [
|
|
390
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWT
|
|
391
|
+
/\b(?:ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
|
|
392
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g,
|
|
393
|
+
/\bAKIA[A-Z0-9]{16}\b/g, // AWS access key id
|
|
394
|
+
/\b(?:sk|pylf|xox[abp])[-_][A-Za-z0-9_-]{12,}\b/g, // OpenAI / Logfire / Slack shapes
|
|
395
|
+
];
|
|
396
|
+
const ASSIGNED_SECRET = /((?:api[_-]?key|apikey|token|secret|password|passwd|credential|private[_-]?key|access[_-]?key)[A-Za-z0-9_-]*\s*[=:]\s*["']?)([^\s"',;)}]{8,})/gi;
|
|
397
|
+
function maskValue(v) {
|
|
398
|
+
return v.length <= 6 ? "*".repeat(v.length) : `${v.slice(0, 3)}***${v.slice(-2)}`;
|
|
399
|
+
}
|
|
400
|
+
export function maskSecretsForReport(text) {
|
|
401
|
+
let out = text;
|
|
402
|
+
for (const re of SECRET_SHAPES)
|
|
403
|
+
out = out.replace(re, (m) => maskValue(m));
|
|
404
|
+
out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The report a tester can hand over without editing it first.
|
|
409
|
+
*
|
|
410
|
+
* Field request (2026-09-09): "make a way to ask AI to generate a usage
|
|
411
|
+
* report into an .md file that can be sent to you. that way you can get
|
|
412
|
+
* great bug reports" — from a user whose agent had just done exactly that
|
|
413
|
+
* by reading index.db by hand. Sections: what doctor saw, the local
|
|
414
|
+
* tool-call ledger (counts and timings, never prompt or file contents), the
|
|
415
|
+
* daemon log's warnings, the config. Secret-shaped values are masked
|
|
416
|
+
* everywhere; paths stay, and the header says so.
|
|
417
|
+
*/
|
|
418
|
+
export function buildDoctorReport(i) {
|
|
419
|
+
const head = [
|
|
420
|
+
`Generated ${i.generatedAt} · vexp-cli ${i.cliVersion} · binary ${i.binaryVersion ?? "not runnable"} · ${i.platform} · node ${i.nodeVersion}${i.wsl ? " · WSL" : ""}`,
|
|
421
|
+
`Workspace: ${i.root}`,
|
|
422
|
+
].join("\n");
|
|
423
|
+
const parts = [
|
|
424
|
+
"# vexp report",
|
|
425
|
+
"",
|
|
426
|
+
head,
|
|
427
|
+
"",
|
|
428
|
+
"Written by `vexp doctor --report`. It holds the doctor output, the local tool-call ledger " +
|
|
429
|
+
"(counts and timings only — never prompt text or file contents), the daemon log's warnings " +
|
|
430
|
+
"and errors, and the vexp config. Secret-shaped values are masked; file paths are visible. " +
|
|
431
|
+
`Review it, then send it to ${SUPPORT_EMAIL} or attach it to your issue.`,
|
|
432
|
+
];
|
|
433
|
+
if (i.mountNotice)
|
|
434
|
+
parts.push("", `> **Filesystem:** ${i.mountNotice}`);
|
|
435
|
+
parts.push("", "## Doctor", "", "```", stripAnsi(i.doctorText).trim(), "```");
|
|
436
|
+
parts.push("", "## Usage ledger", "");
|
|
437
|
+
parts.push(i.ledgerMarkdown?.trim() || `ledger unavailable: ${i.ledgerError ?? "unknown error"}`);
|
|
438
|
+
parts.push("", `## Daemon log${i.logName ? ` (.vexp/${i.logName})` : ""}: warnings and errors`, "");
|
|
439
|
+
if (!i.logName)
|
|
440
|
+
parts.push("no daemon log found under .vexp/");
|
|
441
|
+
else if (i.logWarnings.length === 0)
|
|
442
|
+
parts.push("no warnings or errors in the last 3000 lines");
|
|
443
|
+
else
|
|
444
|
+
parts.push("```", ...i.logWarnings, "```");
|
|
445
|
+
parts.push("", "## Config (.vexp/vexp.toml)", "");
|
|
446
|
+
parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
|
|
447
|
+
parts.push("");
|
|
448
|
+
return maskSecretsForReport(parts.join("\n"));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Warnings and errors from the daemon log tail, ANSI stripped, newest last.
|
|
452
|
+
* Repeats of one message (the same config warning on every start) collapse
|
|
453
|
+
* into the latest occurrence with a count: a report is read by a person,
|
|
454
|
+
* and eighty identical lines hide the one that differs.
|
|
455
|
+
*/
|
|
456
|
+
export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
457
|
+
const lines = stripAnsi(text).trimEnd().split("\n").slice(-scan);
|
|
458
|
+
const hits = lines.filter((l) => /\b(WARN|ERROR|panicked)\b/.test(l));
|
|
459
|
+
const byMessage = new Map();
|
|
460
|
+
for (const l of hits) {
|
|
461
|
+
const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
|
|
462
|
+
const cur = byMessage.get(key);
|
|
463
|
+
if (cur) {
|
|
464
|
+
cur.last = l;
|
|
465
|
+
cur.count++;
|
|
466
|
+
byMessage.delete(key); // re-insert so the map stays in last-seen order
|
|
467
|
+
byMessage.set(key, cur);
|
|
468
|
+
}
|
|
469
|
+
else
|
|
470
|
+
byMessage.set(key, { last: l, count: 1 });
|
|
471
|
+
}
|
|
472
|
+
return [...byMessage.values()]
|
|
473
|
+
.map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
|
|
474
|
+
.slice(-keep);
|
|
475
|
+
}
|
|
476
|
+
/** Gather every input on this machine and write the report. Returns its path. */
|
|
477
|
+
export async function writeDoctorReport(o) {
|
|
478
|
+
let bin = null;
|
|
479
|
+
try {
|
|
480
|
+
const { getBinaryPath } = await import("./binary.js");
|
|
481
|
+
bin = getBinaryPath();
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
/* no binary: the report says so and still carries everything else */
|
|
485
|
+
}
|
|
486
|
+
const run = (args, timeout) => bin ? spawnSync(bin, args, { cwd: o.root, timeout, encoding: "utf-8" }) : null;
|
|
487
|
+
const ver = run(["--version"], 5000);
|
|
488
|
+
const binaryVersion = ver && ver.status === 0 ? String(ver.stdout).trim().split(/\s+/).pop() ?? null : null;
|
|
489
|
+
const ledger = run(["usage-report", "--days", "30"], 30000);
|
|
490
|
+
let ledgerMarkdown = null;
|
|
491
|
+
let ledgerError;
|
|
492
|
+
if (!ledger)
|
|
493
|
+
ledgerError = "vexp-core binary not found";
|
|
494
|
+
else if (ledger.error)
|
|
495
|
+
ledgerError = ledger.error.message;
|
|
496
|
+
else if (ledger.status !== 0) {
|
|
497
|
+
const err = String(ledger.stderr ?? "").trim().split("\n")[0];
|
|
498
|
+
ledgerError = /unrecognized subcommand|unexpected argument/.test(err)
|
|
499
|
+
? `binary ${binaryVersion ?? "?"} predates usage-report (3.1.3) — update vexp-core`
|
|
500
|
+
: err || `exit ${ledger.status}`;
|
|
501
|
+
}
|
|
502
|
+
else
|
|
503
|
+
ledgerMarkdown = String(ledger.stdout);
|
|
504
|
+
let logName = null;
|
|
505
|
+
let logWarnings = [];
|
|
506
|
+
for (const name of ["daemon.log", "vexp.log"]) {
|
|
507
|
+
try {
|
|
508
|
+
const text = fs.readFileSync(path.join(o.root, ".vexp", name), "utf-8");
|
|
509
|
+
logName = name;
|
|
510
|
+
logWarnings = logWarningsFrom(text);
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* try the next name */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
let configText = null;
|
|
518
|
+
try {
|
|
519
|
+
configText = fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml"), "utf-8");
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
/* absent: defaults */
|
|
523
|
+
}
|
|
524
|
+
const md = buildDoctorReport({
|
|
525
|
+
generatedAt: new Date().toISOString(),
|
|
526
|
+
cliVersion: CLI_VERSION,
|
|
527
|
+
binaryVersion,
|
|
528
|
+
platform: `${process.platform} ${process.arch}`,
|
|
529
|
+
nodeVersion: process.version,
|
|
530
|
+
wsl: isWsl(),
|
|
531
|
+
root: o.root,
|
|
532
|
+
doctorText: o.doctorText,
|
|
533
|
+
ledgerMarkdown,
|
|
534
|
+
ledgerError,
|
|
535
|
+
logName,
|
|
536
|
+
logWarnings,
|
|
537
|
+
configText,
|
|
538
|
+
mountNotice: slowMountNotice(o.root),
|
|
539
|
+
});
|
|
540
|
+
const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
|
|
541
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
542
|
+
fs.writeFileSync(dest, md);
|
|
543
|
+
return dest;
|
|
544
|
+
}
|
|
545
|
+
export async function runDoctor(opts = {}) {
|
|
546
|
+
// --report: tee everything doctor prints into the report, colors stripped.
|
|
547
|
+
const captured = [];
|
|
548
|
+
const origLog = console.log;
|
|
549
|
+
if (opts.report) {
|
|
550
|
+
console.log = (...args) => {
|
|
551
|
+
origLog(...args);
|
|
552
|
+
captured.push(stripAnsi(args.map((a) => String(a)).join(" ")));
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
let reportRoot = null;
|
|
556
|
+
try {
|
|
557
|
+
await doctorChecks((root) => {
|
|
558
|
+
reportRoot = root;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
console.log = origLog;
|
|
563
|
+
}
|
|
564
|
+
if (opts.report) {
|
|
565
|
+
const dest = await writeDoctorReport({
|
|
566
|
+
root: reportRoot ?? process.cwd(),
|
|
567
|
+
doctorText: captured.join("\n"),
|
|
568
|
+
outFile: typeof opts.report === "string" ? opts.report : undefined,
|
|
569
|
+
});
|
|
570
|
+
const kb = Math.max(1, Math.round(fs.statSync(dest).size / 1024));
|
|
571
|
+
console.log(` [${OK}] report written to ${dest} (${kb} KB) — review it, then send it to ${SUPPORT_EMAIL}`);
|
|
572
|
+
console.log("");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function doctorChecks(onWorkspace) {
|
|
344
576
|
const home = vexpHome();
|
|
345
577
|
const regPath = path.join(home, ".vexp", "daemons.json");
|
|
346
578
|
let registry = {};
|
|
@@ -365,6 +597,7 @@ export async function runDoctor() {
|
|
|
365
597
|
const ws = member
|
|
366
598
|
? { root: member.parentRoot, source: `repo '${member.alias}' of the multi-repo workspace, linked by ${member.via}` }
|
|
367
599
|
: resolved;
|
|
600
|
+
onWorkspace(ws.root);
|
|
368
601
|
console.log(` cwd: ${process.cwd()}`);
|
|
369
602
|
if (member) {
|
|
370
603
|
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`);
|
|
@@ -388,15 +621,36 @@ export async function runDoctor() {
|
|
|
388
621
|
line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
|
|
389
622
|
if (!live)
|
|
390
623
|
console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
|
|
624
|
+
// The /tmp relocation is silent by construction — every client computes
|
|
625
|
+
// the same fallback and none of them says so. A tester measured his
|
|
626
|
+
// Claude Code worktrees at 89 of the 100 characters and pointed out that
|
|
627
|
+
// a longer auto-generated name would move the socket with nothing
|
|
628
|
+
// announcing it. Announce it here, and the margin before it.
|
|
629
|
+
const m = socketPathMargin(ws.root);
|
|
630
|
+
if (m.relocated) {
|
|
631
|
+
line(OK, `socket lives in /tmp because ${m.candidate} is ${m.length} chars, over the ${m.limit} a Unix socket path allows — every vexp client computes the same fallback, nothing is missing`);
|
|
632
|
+
}
|
|
633
|
+
else if (Number.isFinite(m.margin) && m.margin <= 15) {
|
|
634
|
+
line(WARN, `socket path is ${m.length} of ${m.limit} chars (${m.margin} to spare): a longer directory name — a Claude Code worktree is auto-named — moves this workspace's socket to /tmp/vexp-<hash>.sock. Nothing breaks; when a daemon looks absent, read the 'daemon socket' line above first`);
|
|
635
|
+
}
|
|
391
636
|
// 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
|
|
392
637
|
// say "LLM enabled" while the daemon serves rule-compressed output (config
|
|
393
638
|
// race / stale daemon / non-LLM build); only the daemon knows what's active.
|
|
394
639
|
if (live) {
|
|
395
640
|
const st = await queryDaemon(sock, "index_status");
|
|
396
641
|
if (st) {
|
|
642
|
+
// A daemon that is still loading its model answers "rule", which reads
|
|
643
|
+
// as a downgrade for the ~20s it takes to settle. The daemon already
|
|
644
|
+
// says which of the two it is; doctor was printing the label and
|
|
645
|
+
// dropping the reason (tier-4 field report, 2026-09-05).
|
|
646
|
+
const loading = st.compressor !== "llm"
|
|
647
|
+
&& typeof st.llm_inactive_reason === "string"
|
|
648
|
+
&& /still loading|finishing its startup/i.test(st.llm_inactive_reason);
|
|
397
649
|
const comp = st.compressor === "llm"
|
|
398
650
|
? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
|
|
399
|
-
:
|
|
651
|
+
: loading
|
|
652
|
+
? "loading (the model is still starting — this is not the rule compressor settling in)"
|
|
653
|
+
: st.compressor ?? "unknown (older daemon)";
|
|
400
654
|
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
401
655
|
if (st.llm_configured_but_inactive === true) {
|
|
402
656
|
// The daemon knows WHY, and doctor was throwing it away: it printed
|
|
@@ -644,6 +898,17 @@ export async function runDoctor() {
|
|
|
644
898
|
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
899
|
}
|
|
646
900
|
}
|
|
901
|
+
// 5a-ter) Filesystem: a workspace behind an OS boundary (9p, drvfs, a
|
|
902
|
+
// network share) makes every vexp call 10-50x slower and reads as a vexp
|
|
903
|
+
// bug (field report, 2026-09-09: verify_done at 41-170 s on /app over 9p,
|
|
904
|
+
// every other line of doctor green).
|
|
905
|
+
{
|
|
906
|
+
const slow = slowMountNotice(ws.root);
|
|
907
|
+
if (slow) {
|
|
908
|
+
console.log(chalk.bold("\nFilesystem"));
|
|
909
|
+
line(WARN, slow);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
647
912
|
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
648
913
|
// just check presence. A shell-form command that word-splits on a project
|
|
649
914
|
// path containing a space fails non-blocking on every call: the guard never
|
|
@@ -1004,22 +1269,72 @@ export async function runDoctor() {
|
|
|
1004
1269
|
}
|
|
1005
1270
|
}
|
|
1006
1271
|
}
|
|
1272
|
+
// A supervisor from an older install respawning the current bundle. It
|
|
1273
|
+
// keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
|
|
1274
|
+
// stop it: the old process runs the old comparison. The only fix is to end
|
|
1275
|
+
// it, so doctor has to name it — with its pid, which the record now
|
|
1276
|
+
// carries. A tester spent a night on a :7821 server that came back with a
|
|
1277
|
+
// new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
|
|
1278
|
+
// was not installed anywhere: the LABEL came from a `vexp serve` resident
|
|
1279
|
+
// since before the upgrade, holding its own version in memory
|
|
1280
|
+
// (field report, 2026-09-06).
|
|
1281
|
+
const staleSupervisorLine = (rec) => {
|
|
1282
|
+
const v = supervisorVerdict(rec, CLI_VERSION, {
|
|
1283
|
+
parentOf: parentPid,
|
|
1284
|
+
alive: isAlive,
|
|
1285
|
+
});
|
|
1286
|
+
if (v.kind === "ok")
|
|
1287
|
+
return;
|
|
1288
|
+
if (v.kind === "unknown") {
|
|
1289
|
+
line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
|
|
1290
|
+
` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
|
|
1294
|
+
const provenance = v.by
|
|
1295
|
+
? `running v${v.by}, not v${CLI_VERSION}`
|
|
1296
|
+
: `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
|
|
1297
|
+
line(WARN, `that server was spawned by a ${who} ${provenance}` +
|
|
1298
|
+
(v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
|
|
1299
|
+
`\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
|
|
1300
|
+
// Ending it is the whole remediation: login autostart (or the next
|
|
1301
|
+
// `vexp` command) starts a fresh supervisor, which reads the current
|
|
1302
|
+
// package.json and serves the current build. The tester who did this
|
|
1303
|
+
// had a correct server one second later without running anything
|
|
1304
|
+
// else — and the `vexp setup` this line used to recommend blocks on
|
|
1305
|
+
// an interactive prompt, which for the reader of a doctor line is a
|
|
1306
|
+
// hang, not a fix (field report, 2026-09-07).
|
|
1307
|
+
(v.alive
|
|
1308
|
+
? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
|
|
1309
|
+
// The order is load-bearing and nothing said so: an old
|
|
1310
|
+
// supervisor's health loop rebuilds the daemon registry every 60s
|
|
1311
|
+
// from its own rows, so it deletes each daemon's registration as
|
|
1312
|
+
// fast as the daemon writes it. Restart the daemons first and they
|
|
1313
|
+
// come back invisible — and nobody restarts a daemon they have
|
|
1314
|
+
// just restarted (field report, 2026-09-08).
|
|
1315
|
+
`\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
|
|
1316
|
+
`\n restarted before it is gone comes back invisible to 'vexp daemons'.`
|
|
1317
|
+
: ""));
|
|
1318
|
+
};
|
|
1007
1319
|
// 6) HTTP MCP supervisor.
|
|
1008
1320
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
1009
1321
|
try {
|
|
1010
1322
|
const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
|
|
1011
1323
|
const alive = isAlive(rec.pid);
|
|
1012
1324
|
line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
|
|
1325
|
+
if (alive && rec.version === CLI_VERSION)
|
|
1326
|
+
staleSupervisorLine(rec);
|
|
1013
1327
|
// The supervisor is replaced on version mismatch only when a vexp command
|
|
1014
1328
|
// next runs; until then every HTTP client (Codex http transport, a
|
|
1015
1329
|
// `serverUrl` entry) talks to the OLD build — which advertises the old
|
|
1016
1330
|
// tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
|
|
1017
1331
|
// saw two vexp servers with different tool counts and could not tell why.
|
|
1018
1332
|
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' (
|
|
1333
|
+
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`);
|
|
1334
|
+
staleSupervisorLine(rec);
|
|
1020
1335
|
}
|
|
1021
1336
|
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'
|
|
1337
|
+
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}`);
|
|
1023
1338
|
}
|
|
1024
1339
|
}
|
|
1025
1340
|
catch {
|