vexp-cli 3.1.1 → 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.
@@ -2918,7 +2918,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2918
2918
  let root = {};
2919
2919
  if (fs.existsSync(hooksJsonPath)) {
2920
2920
  try {
2921
- root = JSON.parse(fs.readFileSync(hooksJsonPath, "utf-8"));
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,24 @@ 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
- if (!eff)
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
+ console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
78
+ console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
70
79
  return;
80
+ }
71
81
  let binaryPath;
72
82
  try {
73
83
  binaryPath = getBinaryPath();
74
84
  }
75
- catch {
85
+ catch (err) {
86
+ console.error(chalk.yellow(` ⚠ vexp-core binary not resolved (${err instanceof Error ? err.message : err}) — nothing was started or updated.`));
76
87
  return;
77
88
  }
78
89
  // Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
@@ -274,7 +285,12 @@ async function ensureBootstrap(workspaceRoot, binaryPath) {
274
285
  try {
275
286
  await ensureMcpHttpServer({ owner: "cli" });
276
287
  }
277
- catch { /* non-fatal */ }
288
+ catch (err) {
289
+ // Non-fatal, but never silent: this is the step that replaces an MCP
290
+ // server left behind by an older install, and skipping it leaves every
291
+ // MCP client on the old build.
292
+ console.error(chalk.dim(` ⚠ MCP HTTP server not started or taken over: ${err instanceof Error ? err.message : err}`));
293
+ }
278
294
  }
279
295
  /**
280
296
  * Start the vexp daemon and MCP HTTP server in the background.
@@ -725,6 +741,21 @@ program
725
741
  selectedNames = requested.map((n) => resolveAgentName(n));
726
742
  console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
727
743
  }
744
+ else if (!process.stdin.isTTY) {
745
+ // No one is at the keyboard. This command is named as a remediation
746
+ // in doctor's own output, and an agent — or a CI step, or a script
747
+ // reading that line — cannot answer a prompt: it hangs until it is
748
+ // killed, which is worse than failing (field report, 2026-09-07: a
749
+ // tester followed doctor's advice and had to abandon the command).
750
+ // Configure what is detected, and say that is what happened.
751
+ selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
752
+ if (selectedNames.length === 0) {
753
+ console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
754
+ console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
755
+ process.exit(1);
756
+ }
757
+ console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
758
+ }
728
759
  else {
729
760
  // Interactive selection
730
761
  console.log(chalk.bold("\n Select AI agents to configure:\n"));
package/dist/doctor.js CHANGED
@@ -207,7 +207,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
207
207
  * wherever it stands. Null when nothing was skipped for size, or when the
208
208
  * file predates 3.1 and has no skip keys.
209
209
  */
210
- export function coverageVerdict(cov) {
210
+ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
211
211
  if (!cov || typeof cov !== "object")
212
212
  return null;
213
213
  const c = cov;
@@ -225,7 +225,12 @@ export function coverageVerdict(cov) {
225
225
  level: WARN,
226
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
227
  examples.map((e) => ` - ${e}`).join("\n") +
228
- (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
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}` +
229
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').`,
230
235
  };
231
236
  }
@@ -334,7 +339,7 @@ export function workspaceCoverageFindings(root) {
334
339
  catch {
335
340
  continue; /* never indexed, or an index older than 2.7 */
336
341
  }
337
- const v = coverageVerdict(cov);
342
+ const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
338
343
  if (v)
339
344
  out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
340
345
  }
@@ -394,9 +399,18 @@ export async function runDoctor() {
394
399
  if (live) {
395
400
  const st = await queryDaemon(sock, "index_status");
396
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);
397
409
  const comp = st.compressor === "llm"
398
410
  ? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
399
- : st.compressor ?? "unknown (older daemon)";
411
+ : loading
412
+ ? "loading (the model is still starting — this is not the rule compressor settling in)"
413
+ : st.compressor ?? "unknown (older daemon)";
400
414
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
401
415
  if (st.llm_configured_but_inactive === true) {
402
416
  // The daemon knows WHY, and doctor was throwing it away: it printed
@@ -1004,22 +1018,53 @@ export async function runDoctor() {
1004
1018
  }
1005
1019
  }
1006
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
+ };
1007
1049
  // 6) HTTP MCP supervisor.
1008
1050
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
1009
1051
  try {
1010
1052
  const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
1011
1053
  const alive = isAlive(rec.pid);
1012
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);
1013
1057
  // The supervisor is replaced on version mismatch only when a vexp command
1014
1058
  // next runs; until then every HTTP client (Codex http transport, a
1015
1059
  // `serverUrl` entry) talks to the OLD build — which advertises the old
1016
1060
  // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
1017
1061
  // saw two vexp servers with different tool counts and could not tell why.
1018
1062
  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`);
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);
1020
1065
  }
1021
1066
  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}`);
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}`);
1023
1068
  }
1024
1069
  }
1025
1070
  catch {
@@ -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 OUR version.
108
- // Field report (Nathan): a 2.3.0 http server stayed authoritative for
109
- // 9 days across extension upgrades because reuse never compared
110
- // versions. On mismatch (or a legacy record without one) we take over:
111
- // SIGTERM the old listener and spawn the current build.
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 === CLI_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({ pid, port, startedAt: Date.now(), owner, version: CLI_VERSION });
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
@@ -207,7 +207,46 @@ async function resurrectAll() {
207
207
  if (resurrected.get(sock))
208
208
  live[ws] = sock;
209
209
  }
210
- writeRegistry(live);
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;
211
250
  }
212
251
  async function startMcp() {
213
252
  const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);