vexp-cli 3.3.3 → 3.3.6

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/doctor.js CHANGED
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as net from "net";
5
5
  import { execFileSync, spawnSync } from "child_process";
6
+ import { fileURLToPath } from "url";
6
7
  import chalk from "chalk";
7
8
  import { fnvHash, socketPathFor, socketPathMargin } from "./socket-path.js";
8
9
  import { agentConsentPath, decisionFor, loadAgentConsentFile, readAgentConsent } from "./agent-consent.js";
@@ -10,7 +11,7 @@ import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAge
10
11
  import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
11
12
  import { CLI_VERSION } from "./version.js";
12
13
  import { formatCheckIn, registrationState } from "./license.js";
13
- import { parentPid } from "./mcp-supervisor.js";
14
+ import { endProcessCommand, isOlderVersion, parentPid, processCommandLine, vexpProcessCheck } from "./mcp-supervisor.js";
14
15
  import { slowMountNotice } from "./slow-mount.js";
15
16
  import { cloudSyncNotice } from "./cloud-sync.js";
16
17
  import { vexpHome, isStateHome } from "./state-home.js";
@@ -138,15 +139,227 @@ function isAlive(pid) {
138
139
  }
139
140
  }
140
141
  /**
141
- * The git-hooks verdict, as data.
142
+ * The HTTP MCP server section (~/.vexp/mcp.pid), as data.
142
143
  *
143
- * Extracted because it was wrong in a way no test could catch: an overriding
144
- * `core.hooksPath` was reported as a FAILURE claiming "the index does not
145
- * refresh on commit/merge/checkout", and the remedy offered was to edit a
146
- * file that moon, husky and lefthook all regenerate. The claim is untrue —
147
- * the daemon watches the tree live and reconciles it against disk every five
148
- * minutes — and the remedy was unusable. A user spent a round trip on it.
144
+ * Two stories used to be told for every record whose version was not this
145
+ * CLI's. "HTTP clients get the old build - run 'vexp setup' and the current
146
+ * CLI takes it over", and the one of the resident supervisor: a `vexp serve`
147
+ * from an older install respawns its build every 60 s, no takeover rule on
148
+ * this side can stop it, so doctor names it with its pid and says to end it
149
+ * first (field reports, 2026-09-06 and 2026-09-08: a :7821 server that came
150
+ * back with a new pid every two minutes; daemons restarted before the old
151
+ * supervisor was gone came back unregistered).
152
+ *
153
+ * Both are about `vexp serve`. For a server the IDE extension started
154
+ * (owner "plugin") neither holds: the extension has no loop on that server
155
+ * and leaves a newer one alone, the pid on record is the extension host, and
156
+ * its version is the right one for that extension. An extension user who
157
+ * ran doctor through npx, one release ahead, was told to `kill` the VS Code
158
+ * extension host and to run a `vexp setup` that would have moved Claude
159
+ * Code's entry into the npx cache (field report, 2026-10-08). That record
160
+ * now gets one INFO line. The supervisor story is kept for a `vexp serve`
161
+ * and for a record that does not say what wrote it, with two conditions it
162
+ * lacked: the pid is named as a process to end only when its command line
163
+ * still reads as vexp (a pid outlives its process), and the command is the
164
+ * platform's. A server that a `vexp` command started on its way (owner
165
+ * "cli") has no supervisor behind it, and a reader with the extension and
166
+ * no `vexp` command is given no command for any of them.
149
167
  */
168
+ export function httpServerFindings(rec, o) {
169
+ const alive = o.io.alive(rec.pid);
170
+ const out = [
171
+ { level: alive ? OK : WARN, message: `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}` },
172
+ ];
173
+ if (!alive)
174
+ return out;
175
+ const version = typeof rec.version === "string" ? rec.version : undefined;
176
+ // Who talks to this server: of the agents vexp sets up, Codex alone, and
177
+ // only on the http transport.
178
+ const whoTalks = (served) => o.codexHttp
179
+ ? `Codex's vexp entry above is on the http transport, so Codex talks to this server and gets v${served}; no other agent uses it.`
180
+ : `no agent that vexp sets up talks to this server unless Codex's transport is set to http (vexp.codexMcpTransport; the default is direct, and no http entry for Codex was found above).`;
181
+ if (rec.owner === "plugin") {
182
+ const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : version;
183
+ if (version === o.cliVersion && by === o.cliVersion)
184
+ return out;
185
+ const ide = ideName(o.install.extensions, by);
186
+ const whose = `the vexp extension of ${ide === UNNAMED_IDE ? "an IDE" : ide}${by ? `, v${by}` : " (a version from before 2.4, which recorded none)"}`;
187
+ out.push({
188
+ level: INFO,
189
+ message: `this server belongs to ${whose}; ${thisCliIs(o.cliVersion, by, o.install.npxCache)}. They are separate installs, and the server is the right one for that extension.\n` +
190
+ ` ${whoTalks(version ?? by ?? "?")}\n` +
191
+ ` its version changes when the extension is updated in the IDE: there is nothing to end or to set up from here.`,
192
+ });
193
+ return out;
194
+ }
195
+ // The server is NEWER than this CLI: this CLI is the old install, and an
196
+ // older build stands down (ensureMcpHttpServer). Nothing to take over.
197
+ if (version && isOlderVersion(o.cliVersion, version) && !isOlderVersion(version, o.cliVersion)) {
198
+ out.push({
199
+ level: INFO,
200
+ message: `the HTTP MCP server on :${rec.port} is v${version}; ${thisCliIs(o.cliVersion, version, o.install.npxCache)}, and leaves that server alone: an older build never takes over a newer one. To compare like with like: npx vexp-cli@${version} doctor`,
201
+ });
202
+ return out;
203
+ }
204
+ const v = supervisorVerdict(rec, o.cliVersion, { parentOf: o.io.parentOf, alive: o.io.alive });
205
+ if (version === o.cliVersion && v.kind === "ok")
206
+ return out;
207
+ // What runs under the supervisor's pid NOW. A number on record is tested
208
+ // for existence by `alive`, and a reused one belongs to an unrelated
209
+ // program: the number is printed only for a process that still reads as
210
+ // vexp, or for none at all.
211
+ const now = v.kind === "stale" && v.pid !== undefined && v.alive ? vexpProcessCheck(o.io.commandLineOf(v.pid), rec.owner) : undefined;
212
+ // A `vexp serve` is resident and takes the port back; a `vexp` command
213
+ // that started the server on its way (owner "cli") has no loop on it.
214
+ const oneOff = rec.owner === "cli";
215
+ // The reader has the extension and no `vexp` command: the same record is
216
+ // information. "run 'vexp setup'" is a command they do not have, and
217
+ // through npx it is the one that moves their agents' entries into the npx
218
+ // cache; such a server is usually what an earlier npx run left behind
219
+ // (review of the fix, 2026-10-08).
220
+ if (extensionOnly(o.install)) {
221
+ const viaNpx = runsFromNpxCache(o.io.commandLineOf(rec.pid));
222
+ // Only a record that names its owner says whose the server is not.
223
+ const starter = rec.owner === "serve"
224
+ ? " and is not the extension's: a 'vexp serve' started it"
225
+ : oneOff
226
+ ? " and is not the extension's: a 'vexp' command started it"
227
+ : ", and what started it is not on record";
228
+ out.push({
229
+ level: INFO,
230
+ message: `the HTTP MCP server on :${rec.port} is ${version ? `v${version}` : "from before 2.4 (no version on record)"}${starter}${viaNpx ? ", out of an npx cache" : ""}; ` +
231
+ `${thisCliIs(o.cliVersion, version, o.install.npxCache)}.\n` +
232
+ ` ${whoTalks(version ?? "?")}\n` +
233
+ ` the vexp extension replaces a server older than itself when a window opens in ${ideName(o.install.extensions)}, and leaves a newer one alone: there is nothing to run from here.`,
234
+ });
235
+ if (!oneOff && now === "vexp") {
236
+ out.push({
237
+ level: WARN,
238
+ message: `a 'vexp serve'${v.by ? ` v${v.by}` : ""} is still running on this machine (pid ${v.pid}) and starts that server again whenever it ends, so :${rec.port} stays with its build for as long as it runs.\n` +
239
+ ` it was not started by the extension: ${(o.io.commandLineOf(v.pid) ?? "").slice(0, 200)}`,
240
+ });
241
+ }
242
+ return out;
243
+ }
244
+ // The supervisor is replaced on version mismatch only when a vexp command
245
+ // next runs; until then every HTTP client (Codex http transport, a
246
+ // `serverUrl` entry) talks to the OLD build — which advertises the old
247
+ // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
248
+ // saw two vexp servers with different tool counts and could not tell why.
249
+ if (version && version !== o.cliVersion) {
250
+ out.push({ level: WARN, message: `the HTTP MCP server on :${rec.port} is v${version} but this CLI is v${o.cliVersion} — 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` });
251
+ }
252
+ else if (!version) {
253
+ out.push({ level: WARN, message: `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${o.cliVersion}` });
254
+ return out;
255
+ }
256
+ if (v.kind === "ok")
257
+ return out;
258
+ const find = o.platform === "win32"
259
+ ? `in PowerShell: Get-CimInstance Win32_Process | Where-Object CommandLine -like '*vexp*serve*' | Select-Object ProcessId, CommandLine`
260
+ : `ps -ef | grep '[v]exp.*serve'`;
261
+ if (v.kind === "unknown") {
262
+ out.push({
263
+ level: WARN,
264
+ message: `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
265
+ ` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (${find}) and end it.`,
266
+ });
267
+ return out;
268
+ }
269
+ if (oneOff) {
270
+ // Said, not counted: the WARN above is the whole finding. The paragraph
271
+ // below was printed for this record too, and a command that started the
272
+ // server on its way reclaims nothing (review of the fix, 2026-10-08).
273
+ out.push({
274
+ level: NOTE,
275
+ message: `that server was started by a 'vexp' command${v.by ? ` running v${v.by}` : " of an older install"}${now === "vexp" ? ` (pid ${v.pid}, still running)` : ""}, ` +
276
+ `not by a resident 'vexp serve': nothing takes :${rec.port} back on a schedule.`,
277
+ });
278
+ return out;
279
+ }
280
+ const who = rec.owner === "serve" ? "'vexp serve' supervisor" : "process";
281
+ const provenance = v.by
282
+ ? `running v${v.by}, not v${o.cliVersion}`
283
+ : `started before this CLI was installed — its version was not recorded, so it is a build older than v${o.cliVersion}`;
284
+ // A pid that was never recorded was read as the server's parent: when
285
+ // that is another program, the process that started the server has ended
286
+ // and the server was handed to it.
287
+ const recorded = typeof rec.spawnedByPid === "number";
288
+ const pidNote = v.pid === undefined
289
+ ? ""
290
+ : now === "vexp"
291
+ ? ` (pid ${v.pid}, still running)`
292
+ : now === "unreadable"
293
+ ? ` (whether it still runs could not be read: the process under its ${recorded ? "recorded pid" : "parent's pid"} does not show its command line)`
294
+ : now === "other"
295
+ ? recorded
296
+ ? ` (already gone: its recorded pid now belongs to another program)`
297
+ : ` (no longer running: the server's parent process is another program now)`
298
+ : ` (pid ${v.pid}, already gone)`;
299
+ // The supervisor is not running under the pid doctor has for it: whether
300
+ // another one is resident is not known, so its return is a condition, not
301
+ // a statement.
302
+ if (now === "other" || (v.pid !== undefined && !v.alive)) {
303
+ out.push({
304
+ level: WARN,
305
+ message: `that server was spawned by a ${who} ${provenance}${pidNote}` +
306
+ `\n if :${rec.port} goes back to that build after it is replaced, a 'vexp serve' of the older install is running again: find it (${find}) and end it first.`,
307
+ });
308
+ return out;
309
+ }
310
+ out.push({
311
+ level: WARN,
312
+ message: `that server was spawned by a ${who} ${provenance}${pidNote}` +
313
+ `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
314
+ // Ending it is the whole remediation: login autostart (or the next
315
+ // `vexp` command) starts a fresh supervisor, which reads the current
316
+ // package.json and serves the current build. The tester who did this
317
+ // had a correct server one second later without running anything
318
+ // else — and the `vexp setup` this line used to recommend blocks on
319
+ // an interactive prompt, which for the reader of a doctor line is a
320
+ // hang, not a fix (field report, 2026-09-07).
321
+ (now === "vexp"
322
+ ? `\n end it FIRST — '${endProcessCommand(v.pid, o.platform)}'${o.platform === "win32" ? ` (in PowerShell also: Stop-Process -Id ${v.pid} -Force)` : ""} — before restarting any daemon.` +
323
+ // The order is load-bearing and nothing said so: an old
324
+ // supervisor's health loop rebuilds the daemon registry every 60s
325
+ // from its own rows, so it deletes each daemon's registration as
326
+ // fast as the daemon writes it. Restart the daemons first and they
327
+ // come back invisible — and nobody restarts a daemon they have
328
+ // just restarted (field report, 2026-09-08).
329
+ `\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
330
+ `\n restarted before it is gone comes back invisible to 'vexp daemons'.`
331
+ : now === "unreadable"
332
+ ? `\n look for it (${find}): if a 'vexp serve' is listed, end that one FIRST, before restarting any daemon.`
333
+ : ""),
334
+ });
335
+ return out;
336
+ }
337
+ /** The dot-folder of each IDE whose `extensions/` directory holds vexp's
338
+ * extension, with the name doctor gives it. `.vscode-server` is a
339
+ * Remote/WSL install; the forks use their own folder. The table gives the
340
+ * NAME only: whether a path is the extension's is read from the path itself
341
+ * (extensionFolderOf), so a fork that is not listed here is still told
342
+ * apart from a CLI install and is called "your IDE". */
343
+ const IDE_HOMES = [
344
+ [".vscode-server-insiders", "VS Code Insiders (Remote)"],
345
+ [".vscode-server", "VS Code (Remote/WSL)"],
346
+ [".vscode-insiders", "VS Code Insiders"],
347
+ [".vscode-oss", "VSCodium"],
348
+ [".vscode", "VS Code"],
349
+ [".cursor-server", "Cursor (Remote)"],
350
+ [".cursor", "Cursor"],
351
+ [".windsurf-server", "Windsurf (Remote)"],
352
+ [".windsurf", "Windsurf"],
353
+ [".trae", "Trae"],
354
+ [".antigravity", "Antigravity"],
355
+ [".kiro", "Kiro"],
356
+ ];
357
+ /** What doctor calls an IDE it has no name for, or cannot pick among several. */
358
+ const UNNAMED_IDE = "your IDE";
359
+ /** "the VS Code window", and "the window" for an IDE doctor cannot name. */
360
+ function ideWindow(ide) {
361
+ return ide === UNNAMED_IDE ? "the window" : `the ${ide} window`;
362
+ }
150
363
  /**
151
364
  * Which IDE owns a running daemon, from its executable path.
152
365
  *
@@ -160,29 +373,22 @@ function isAlive(pid) {
160
373
  * Matching is on the IDE's own extensions directory, which is where the
161
374
  * bundled core lives (`<ide>/extensions/<publisher>.vexp-<ver>/binaries/...`).
162
375
  * Remote/WSL installs use `.vscode-server`; forks use their own dot-dir.
376
+ *
377
+ * The extension is also on Open VSX, for editors this table does not list.
378
+ * Their daemon was taken for a CLI's, and its owner sent to `vexp daemon-cmd
379
+ * restart`, which through npx puts an engine from the npx cache in the
380
+ * extension's place (review of the fix, 2026-10-08). The vexp folder in the
381
+ * path is enough to know whose daemon it is: such an editor is "your IDE".
163
382
  */
164
383
  export function daemonOwnerIde(exePath) {
165
384
  if (!exePath)
166
385
  return null;
167
386
  const p = exePath.replace(/\\/g, "/").toLowerCase();
168
- const ides = [
169
- [/\/\.vscode-server-insiders\/extensions\//, "VS Code Insiders (Remote)"],
170
- [/\/\.vscode-server\/extensions\//, "VS Code (Remote/WSL)"],
171
- [/\/\.vscode-insiders\/extensions\//, "VS Code Insiders"],
172
- [/\/\.vscode-oss\/extensions\//, "VSCodium"],
173
- [/\/\.vscode\/extensions\//, "VS Code"],
174
- [/\/\.cursor-server\/extensions\//, "Cursor (Remote)"],
175
- [/\/\.cursor\/extensions\//, "Cursor"],
176
- [/\/\.windsurf-server\/extensions\//, "Windsurf (Remote)"],
177
- [/\/\.windsurf\/extensions\//, "Windsurf"],
178
- [/\/\.trae\/extensions\//, "Trae"],
179
- [/\/\.antigravity\/extensions\//, "Antigravity"],
180
- ];
181
- for (const [re, label] of ides) {
182
- if (re.test(p))
387
+ for (const [dir, label] of IDE_HOMES) {
388
+ if (p.includes(`/${dir}/extensions/`))
183
389
  return label;
184
390
  }
185
- return null;
391
+ return extensionFolderOf(exePath) ? UNNAMED_IDE : null;
186
392
  }
187
393
  /** The remedy line for a daemon that is behind the installed engine. */
188
394
  export function staleDaemonRemedy(exePath) {
@@ -190,10 +396,521 @@ export function staleDaemonRemedy(exePath) {
190
396
  if (ide) {
191
397
  return (`this daemon belongs to ${ide} (${exePath}) — 'vexp daemon-cmd restart' will NOT fix it: ` +
192
398
  `the extension host respawns the same build.\n` +
193
- ` reload the ${ide} window instead (Command Palette → "Developer: Reload Window").`);
399
+ ` reload ${ideWindow(ide)} instead (Command Palette → "Developer: Reload Window").`);
194
400
  }
195
401
  return `run 'vexp daemon-cmd restart' to upgrade it now.`;
196
402
  }
403
+ /** `vexp.vexp-vscode-3.3.2-win32-x64` -> 3.3.2; undefined for any other name. */
404
+ function extensionFolderVersion(name) {
405
+ return /^vexp\.vexp(?:-vscode)?-(\d+\.\d+\.\d+)/i.exec(name)?.[1];
406
+ }
407
+ /** The extension folder a path sits in: the folder itself, the extensions
408
+ * directory around it, its name and the version in that name. Null for a
409
+ * path outside any. */
410
+ export function extensionFolderOf(p) {
411
+ if (!p)
412
+ return null;
413
+ const m = /^((.*[\\/]extensions)[\\/](vexp\.vexp(?:-vscode)?-(\d+\.\d+\.\d+)[^\\/]*))(?:[\\/]|$)/i.exec(p);
414
+ return m ? { dir: m[1], extensionsDir: m[2], folder: m[3], version: m[4] } : null;
415
+ }
416
+ /** The newest vexp extension version among the entries of one extensions directory. */
417
+ export function newestExtensionVersion(names) {
418
+ let newest;
419
+ for (const n of names) {
420
+ const v = extensionFolderVersion(n);
421
+ if (v && (newest === undefined || isOlderVersion(newest, v)))
422
+ newest = v;
423
+ }
424
+ return newest;
425
+ }
426
+ /**
427
+ * The vexp extension folders under `home`: in the IDEs doctor has a name
428
+ * for, in the table's order, then in any other `~/.<editor>/extensions`.
429
+ * The second half is what makes a reader of an unlisted editor an extension
430
+ * user at all: with the table alone their install read as "no extension",
431
+ * and every remedy was a `vexp` command again (review of the fix,
432
+ * 2026-10-08).
433
+ */
434
+ export function vexpExtensionInstalls(home, readdir = (d) => fs.readdirSync(d)) {
435
+ const out = [];
436
+ const scan = (dir, ide) => {
437
+ const extensionsDir = path.join(home, dir, "extensions");
438
+ let names;
439
+ try {
440
+ names = readdir(extensionsDir);
441
+ }
442
+ catch {
443
+ return; // no such IDE here
444
+ }
445
+ for (const n of names) {
446
+ const version = extensionFolderVersion(n);
447
+ if (version)
448
+ out.push({ ide, version, dir: path.join(extensionsDir, n) });
449
+ }
450
+ };
451
+ for (const [dir, ide] of IDE_HOMES)
452
+ scan(dir, ide);
453
+ let dotFolders = [];
454
+ try {
455
+ dotFolders = readdir(home).filter((n) => n.startsWith(".") && !IDE_HOMES.some(([dir]) => dir === n));
456
+ }
457
+ catch {
458
+ /* the home cannot be listed: the table is all there is */
459
+ }
460
+ for (const dir of dotFolders.sort())
461
+ scan(dir, UNNAMED_IDE);
462
+ return out;
463
+ }
464
+ /** The npx cache a script runs from: `<npm cache>/_npx/<hash>`, or null. */
465
+ export function npxCacheOf(scriptPath) {
466
+ return /^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/.exec(scriptPath)?.[1] ?? null;
467
+ }
468
+ /** Does a process's command line name a script inside an npx cache? Only
469
+ * that: an argument with a space in it is not told apart from two there,
470
+ * so the cache's own path is not read out of it. */
471
+ function runsFromNpxCache(commandLine) {
472
+ return /[\\/]_npx[\\/][^\\/\s]+[\\/]node_modules[\\/]/.test(commandLine ?? "");
473
+ }
474
+ /**
475
+ * The `vexp` command a person can type in a shell, or null. A PATH entry
476
+ * that is a package tree's own `node_modules/.bin` does not count: npx puts
477
+ * the .bin of the package it fetched on the PATH of the command it runs, and
478
+ * that `vexp` is gone from the reader's shell the moment the run ends. The
479
+ * test is on the folder, so any runner that works the same way is covered.
480
+ */
481
+ export function vexpCommandOnPath(env = process.env, platform = process.platform, isFile = (p) => {
482
+ try {
483
+ return fs.statSync(p).isFile();
484
+ }
485
+ catch {
486
+ return false;
487
+ }
488
+ }) {
489
+ const p = platform === "win32" ? path.win32 : path.posix;
490
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
491
+ const names = platform === "win32" ? ["vexp.cmd", "vexp.exe", "vexp.ps1", "vexp.bat"] : ["vexp"];
492
+ for (const entry of (key ? env[key] ?? "" : "").split(p.delimiter)) {
493
+ // Windows allows an entry in quotes ("C:\Program Files\nodejs"), and
494
+ // resolves commands through it all the same.
495
+ const dir = entry.replace(/^"(.*)"$/, "$1");
496
+ if (!dir || /[\\/]_npx[\\/]/.test(dir) || (p.isAbsolute(dir) && /[\\/]node_modules[\\/]\.bin[\\/]?$/.test(dir)))
497
+ continue;
498
+ for (const name of names) {
499
+ const file = p.join(dir, name);
500
+ if (isFile(file))
501
+ return file;
502
+ }
503
+ }
504
+ return null;
505
+ }
506
+ /** The reader has the extension and no `vexp` command: remedies are IDE
507
+ * actions, never commands. */
508
+ export function extensionOnly(i) {
509
+ return i.cliOnPath === null && i.extensions.length > 0;
510
+ }
511
+ /** The IDE to name in a line: the one that has the extension (at `version`,
512
+ * when given), "your IDE" when none or several do. */
513
+ export function ideName(extensions, version) {
514
+ const at = version ? extensions.filter((e) => e.version === version) : extensions;
515
+ const ides = [...new Set((at.length > 0 ? at : extensions).map((e) => e.ide))];
516
+ return ides.length === 1 ? ides[0] : UNNAMED_IDE;
517
+ }
518
+ /** The three facts on this machine, for this process. */
519
+ function installContextHere() {
520
+ let script = "";
521
+ try {
522
+ script = fileURLToPath(import.meta.url);
523
+ }
524
+ catch {
525
+ /* not a file URL: not an npx cache either */
526
+ }
527
+ return {
528
+ cliOnPath: vexpCommandOnPath(),
529
+ npxCache: npxCacheOf(script),
530
+ extensions: vexpExtensionInstalls(os.homedir()),
531
+ };
532
+ }
533
+ /** How this CLI stands to another vexp build, in words: the newer or the
534
+ * older of the two, and that it came out of an npx cache when it did. */
535
+ function thisCliIs(cliVersion, other, npxCache) {
536
+ const rank = !other
537
+ ? ""
538
+ : isOlderVersion(other, cliVersion)
539
+ ? ", the newer of the two"
540
+ : isOlderVersion(cliVersion, other)
541
+ ? ", the older of the two"
542
+ : "";
543
+ return `this CLI is v${cliVersion}${rank}${npxCache ? ", run from an npx cache" : ""}`;
544
+ }
545
+ /**
546
+ * The first line of doctor when it runs out of an npx cache: which CLI the
547
+ * reader is looking at, and why its version need not be the one installed.
548
+ * Null for an installed CLI, whose doctor starts as it always did.
549
+ */
550
+ export function npxOriginNote(cliVersion, npxCache) {
551
+ if (!npxCache)
552
+ return null;
553
+ return (`this is vexp-cli v${cliVersion}, run from an npx cache (${npxCache}): npx fetches the latest release unless a version is given, ` +
554
+ `so it can be newer than the vexp you have installed. To run a given version: npx vexp-cli@<version> doctor`);
555
+ }
556
+ /**
557
+ * How the reader sets `agents` up for this project, with the install they
558
+ * have. A plain `vexp setup` is not it: since 3.3.0 it leaves an agent found
559
+ * only on the machine alone until it is chosen, so the line that sent a
560
+ * reader there for missing Claude Code hooks installed none of them (review
561
+ * of a support ticket, 2026-10-08).
562
+ */
563
+ export function setupAgentsRemedy(install, agents, root) {
564
+ if (extensionOnly(install)) {
565
+ return `with this project open in ${ideName(install.extensions)}, run "vexp: Setup Agents" from the Command Palette and tick ${agents.join(" and ")}`;
566
+ }
567
+ return `run '${setupAgentsCommand(agents, root)}'`;
568
+ }
569
+ /**
570
+ * The closing tips. For a reader with the extension and no `vexp` command
571
+ * they translate the commands that lines above may still name into the IDE
572
+ * actions that do the same, and say why not to reach for npx: `vexp setup`
573
+ * run out of an npx cache rewrites the agents' entries to point into it.
574
+ */
575
+ export function summaryTips(install, codexUsed) {
576
+ const codex = codexUsed ? "; restart Codex to drop a cached config" : "";
577
+ if (extensionOnly(install)) {
578
+ const ide = ideName(install.extensions);
579
+ return (`Tips: this machine has the vexp extension and no 'vexp' command. Where a line above names one, use ${ide} instead: ` +
580
+ `Command Palette > "vexp: Setup Agents" for 'vexp setup', "vexp: Restart Daemon" for 'vexp daemon-cmd restart'${codex}. ` +
581
+ `Do not run those commands through npx: 'vexp setup' from an npx cache points your agents' entries into that cache.`);
582
+ }
583
+ return `Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs${codex}.`;
584
+ }
585
+ /**
586
+ * What `root` is when vexp never serves it as a project, or undefined: the
587
+ * engine's own two refusals (utils::forbidden_workspace_reason), which is
588
+ * where doctor's walk ends up when it is run outside any project.
589
+ */
590
+ export function notAProject(root, home = os.homedir(), env = process.env) {
591
+ if (env.VEXP_ALLOW_HOME_WORKSPACE === "1")
592
+ return undefined;
593
+ if (samePath(root, home))
594
+ return "your home directory";
595
+ if (isStateHome(root))
596
+ return "the folder VEXP_HOME names, where vexp keeps its own state";
597
+ const abs = path.resolve(root);
598
+ return path.dirname(abs) === abs ? "a filesystem root" : undefined;
599
+ }
600
+ /** A refusal as the engine wrote it, less the sentences that name a `vexp`
601
+ * command: for a reader who has none to type. */
602
+ function withoutVexpCommands(line) {
603
+ return line
604
+ .split(/(?<=[.!?])\s+/)
605
+ .filter((sentence) => !/[`']vexp /.test(sentence))
606
+ .join(" ");
607
+ }
608
+ /**
609
+ * What doctor says when no daemon answers for this workspace.
610
+ *
611
+ * It used to be one WARN and "run: vexp daemon-cmd start", whoever read it.
612
+ * With the IDE extension that is the ordinary state of a project that is not
613
+ * open: the extension starts a project's daemon when its window opens (if
614
+ * the project has an index) and stops it when the window closes. The reader
615
+ * has no `vexp` command, and through npx that remedy starts a daemon out of
616
+ * the npx cache that no window owns or stops (field report, 2026-10-08).
617
+ * Doctor cannot see whether a window is open, so it says what it knows and
618
+ * where to look. A start the engine refused is another matter: its reason
619
+ * stays in .vexp/start-blocked until the next attempt, and no surface of
620
+ * doctor read it.
621
+ *
622
+ * And a root that is no project at all (doctor typed in a terminal that
623
+ * opens in the home folder) has no daemon by design: "start one" is refused
624
+ * by the engine, and "open the project and press Start Indexing" would open
625
+ * the home folder (review of the fix, 2026-10-08).
626
+ */
627
+ export function daemonDownFindings(o) {
628
+ if (o.outside) {
629
+ return [
630
+ {
631
+ level: INFO,
632
+ message: `no daemon is running here (${o.sock}), and none is meant to: doctor was run outside a project. It resolved ${o.outside.root}, ${o.outside.what}, which vexp never serves as one.\n` +
633
+ ` what follows describes this machine and that folder, not a project of yours: run doctor again from the folder of the project you want checked.`,
634
+ },
635
+ ];
636
+ }
637
+ const ext = extensionOnly(o.install);
638
+ const ide = ideName(o.install.extensions);
639
+ const blocked = (o.startBlocked ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
640
+ if (blocked.length > 0) {
641
+ // The engine's own text; for a reader with no `vexp` command, less the
642
+ // sentences that name one. By the sentence and not by the line: the
643
+ // engine's reason can be a single line, and dropping it whole left
644
+ // "was refused:" with nothing after it.
645
+ const lines = ext ? blocked.map(withoutVexpCommands).filter(Boolean) : blocked;
646
+ if (ext && /concurrent-workspace cap/.test(blocked[0]))
647
+ lines.push(`closing ${ideWindow(ide)} of one of them frees a place; then reopen this project.`);
648
+ if (lines.length === 0)
649
+ lines.push("the engine's reason is in .vexp/start-blocked.");
650
+ return [
651
+ {
652
+ level: WARN,
653
+ message: `daemon socket NOT reachable: ${o.sock}\n the last start of this project's daemon was refused: ${lines.join("\n ")}`,
654
+ },
655
+ ];
656
+ }
657
+ if (ext && !o.hasIndex) {
658
+ return [
659
+ {
660
+ level: INFO,
661
+ message: `no daemon is running for this project (${o.sock}), and the project has no index yet (.vexp/index.db): the vexp extension starts a daemon only for a project that has one.\n` +
662
+ ` open the project in ${ide} and press "Start Indexing" in the vexp sidebar.`,
663
+ },
664
+ ];
665
+ }
666
+ if (ext) {
667
+ return [
668
+ {
669
+ level: INFO,
670
+ message: `no daemon is running for this project (${o.sock}). With the vexp extension that is the state of a project that is not open in ${ide}: ` +
671
+ `the extension starts a project's daemon when its window opens and stops it when the window closes.\n` +
672
+ ` if the project is open in a window right now, look at the vexp item in that window's status bar and at .vexp/vexp.log.`,
673
+ },
674
+ ];
675
+ }
676
+ return [
677
+ { level: WARN, message: `daemon socket NOT reachable: ${o.sock}` },
678
+ { level: NOTE, message: `→ run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')` },
679
+ ];
680
+ }
681
+ /**
682
+ * The line for a daemon whose version is not this CLI's engine's, or whose
683
+ * executable is gone from disk. Null when there is nothing to say.
684
+ *
685
+ * Doctor compared the two with `!==`, called its own engine "the installed
686
+ * binary" and the daemon "the OLD version", and told the owner of an IDE
687
+ * daemon to reload the window. For an extension user who ran doctor through
688
+ * npx the installed engine IS the extension's: reloading starts the same
689
+ * build again, and only an extension update changes it (field report,
690
+ * 2026-10-08). The reload advice is kept for the case it was written for,
691
+ * told apart by what is on disk: a newer extension folder beside the one the
692
+ * daemon runs from, or that folder gone.
693
+ */
694
+ export function daemonVersionFinding(o) {
695
+ const differ = !!o.engine && !!o.running && o.engine !== o.running;
696
+ const ide = daemonOwnerIde(o.exe);
697
+ const own = ide ? extensionFolderOf(o.exe)?.version ?? o.running : undefined;
698
+ if (ide) {
699
+ // Whatever this CLI's version: the extension was updated under a window
700
+ // that is still open.
701
+ const onDisk = newestExtensionVersion(o.siblings ?? []);
702
+ if (onDisk !== undefined && own !== undefined && isOlderVersion(own, onDisk)) {
703
+ return {
704
+ level: WARN,
705
+ message: `daemon is v${o.running ?? own}, and the vexp extension of ${ide} is v${onDisk} on disk: this window still runs the build it started with.\n ${staleDaemonRemedy(o.exe)}`,
706
+ };
707
+ }
708
+ }
709
+ if (!differ && o.binaryStale !== true)
710
+ return null;
711
+ if (ide) {
712
+ if (o.binaryStale === true) {
713
+ return { level: WARN, message: `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(o.exe)}` };
714
+ }
715
+ // Two installs at two versions, and nothing stale on either side.
716
+ const cli = thisCliIs(o.cliVersion, o.running, o.npxCache) + (o.engine && o.engine !== o.cliVersion ? `, with an engine at v${o.engine}` : "");
717
+ return {
718
+ level: INFO,
719
+ message: `daemon is v${o.running}: the engine of the vexp extension of ${ide}${own ? ` (v${own})` : ""}, which is the version that answers your agents in this project.\n` +
720
+ ` ${cli}. The two are separate installs, and nothing here is stale.\n` +
721
+ ` to compare like with like: npx vexp-cli@${own ?? o.running} doctor\n` +
722
+ ` the daemon changes version when the extension is updated in ${ide}.`,
723
+ };
724
+ }
725
+ if (!differ) {
726
+ return { level: WARN, message: `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(o.exe)}` };
727
+ }
728
+ // Ordered: a daemon NEWER than this CLI is not "the OLD version", and a
729
+ // restart from here would put the older engine in its place.
730
+ if (isOlderVersion(o.engine, o.running) && !isOlderVersion(o.running, o.engine)) {
731
+ return {
732
+ level: INFO,
733
+ message: `daemon is v${o.running}, newer than this CLI's engine (v${o.engine}): ${thisCliIs(o.cliVersion, o.running, o.npxCache)}. ` +
734
+ `Do not restart the daemon from this CLI: that would put the older engine in its place. To compare like with like: npx vexp-cli@${o.running} doctor`,
735
+ };
736
+ }
737
+ return {
738
+ level: WARN,
739
+ message: `daemon is v${o.running} but this CLI's engine is v${o.engine} — this workspace is still served by the OLD version.\n ${staleDaemonRemedy(o.exe)}`,
740
+ };
741
+ }
742
+ /** A path doctor can test from where it runs: absolute in this platform's
743
+ * own form, with nothing in it an agent would expand first (`${HOME}`,
744
+ * `%APPDATA%`). A path in the other platform's form belongs to the other
745
+ * side of a bridge (`wsl.exe ... node /home/...` in a Windows-side config). */
746
+ function testablePath(v, platform) {
747
+ if (typeof v !== "string" || v.includes("${"))
748
+ return false;
749
+ return platform === "win32" ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(v) && !/%[^%\\/]+%/.test(v) : v.startsWith("/");
750
+ }
751
+ /** Programs that run their arguments somewhere else: the paths after them
752
+ * are not this machine's to look for. */
753
+ const RUNS_ELSEWHERE = /^(?:wsl|ssh|docker|podman)(?:\.exe)?$/i;
754
+ /**
755
+ * What an MCP entry starts: the program, and the script it hands to a node.
756
+ * Only what doctor can test. A bare `node` or `vexp-core` is resolved through
757
+ * the agent's own PATH, a relative path from the agent's folder, `~` and
758
+ * `${HOME}` by the agent: looking for those from here failed entries that
759
+ * work, with exit 1 and a remedy that would have rewritten the user's own
760
+ * entry (review of the fix, 2026-10-08). The script is a .js argument that
761
+ * is not the value of an option before it: `--workspace /srv/three.js` names
762
+ * a project.
763
+ */
764
+ export function mcpEntryTargets(command, args, platform = process.platform) {
765
+ const out = {};
766
+ if (testablePath(command, platform))
767
+ out.command = command;
768
+ if (typeof command === "string" && RUNS_ELSEWHERE.test(command.split(/[\\/]/).pop() ?? ""))
769
+ return out;
770
+ const list = Array.isArray(args) ? args : [];
771
+ const script = list.find((a, i) => testablePath(a, platform) && /\.[cm]?js$/i.test(a) && !(i > 0 && typeof list[i - 1] === "string" && /^-[^=]*$/.test(list[i - 1])));
772
+ if (script)
773
+ out.script = script;
774
+ return out;
775
+ }
776
+ /**
777
+ * The machine-wide vexp entry of an agent (~/.claude.json, Codex's
778
+ * config.toml), checked for what it starts. `fail` when the program or the
779
+ * script it names is not on disk; `note` when both are there but inside an
780
+ * npx cache, or inside an extension folder older than one installed beside
781
+ * it.
782
+ *
783
+ * Both entries hold absolute paths into `vexp.vexp-vscode-<version>`, and an
784
+ * IDE removes that folder some time after an update. They are rewritten only
785
+ * by a start in a project where that agent is set up, so a user who updates
786
+ * and then opens another project keeps two entries that point at nothing:
787
+ * the agent has no vexp tools in any project, vexp says nothing, and doctor
788
+ * printed [OK] for both because it never looked (field report, 2026-10-08).
789
+ *
790
+ * The reason and the repair are read, not assumed: the folder is looked for
791
+ * before it is called gone (a file missing from an installed extension is
792
+ * not repaired by rewriting the entry to the same path), and the IDE is
793
+ * offered only where a vexp extension is installed to do the rewriting
794
+ * (review of the fix, 2026-10-08).
795
+ */
796
+ export function machineEntryCheck(o) {
797
+ const exists = o.exists ?? ((p) => fs.existsSync(p));
798
+ const readdir = o.readdir ?? ((d) => fs.readdirSync(d));
799
+ const targets = mcpEntryTargets(o.command, o.args, o.platform);
800
+ const missing = [targets.command, targets.script].filter((p) => !!p && !exists(p));
801
+ // The extension's own entry names its folder in one of the two.
802
+ const inExtension = [targets.command, targets.script].find((p) => extensionFolderOf(p) !== null);
803
+ if (missing.length > 0) {
804
+ const lost = extensionFolderOf(missing.find((p) => extensionFolderOf(p) !== null));
805
+ const ide = (inExtension ? daemonOwnerIde(inExtension) : null) ?? ideName(o.install.extensions);
806
+ const starts = `vexp's entry starts ${missing.join(" and ")}, which ${missing.length > 1 ? "do" : "does"} not exist`;
807
+ if (lost && exists(lost.dir)) {
808
+ // The extension is installed and a file of it is not there. Whoever
809
+ // rewrites the entry writes this same path again.
810
+ return {
811
+ fail: `${starts} (the folder of the vexp extension v${lost.version} is there, and this file is missing from it) - ${o.agent} cannot start vexp until the file is back.\n` +
812
+ ` to repair it: reinstall the vexp extension in ${ide} (uninstall it in the Extensions view, then install it again). The entry is right as it is.`,
813
+ };
814
+ }
815
+ const why = lost
816
+ ? ` (the folder of the vexp extension v${lost.version} is gone: an IDE removes the old one after an update)`
817
+ : missing.some((p) => npxCacheOf(p) !== null)
818
+ ? " (a path inside an npx cache, which is not a lasting install)"
819
+ : "";
820
+ if (o.machineWide === false) {
821
+ return { fail: `${starts}${why} - ${o.agent} cannot start vexp in this project. vexp does not write this file: correct the path in it, or remove the entry.` };
822
+ }
823
+ if (o.managed === false) {
824
+ return {
825
+ fail: `${starts}${why} - ${o.agent} has no vexp tools in any project. vexp did not write this entry (it has no '# vexp-managed' line) and leaves it as it finds it: correct the path in it, or remove the entry.`,
826
+ };
827
+ }
828
+ const tools = `${starts}${why} - ${o.agent} has no vexp tools in any project until the entry is rewritten.\n`;
829
+ const inIde = `open in ${ide} a project where ${o.agent} is set up (the extension rewrites the entry when the window opens), or run "vexp: Setup Agents" from the Command Palette there with ${o.agent} ticked`;
830
+ const dropIt = `If you no longer use ${o.agent} with vexp, delete the entry instead.`;
831
+ const hasExtension = o.install.extensions.length > 0;
832
+ // An entry of the extension's is repaired from the IDE, whoever reads
833
+ // this, as long as the extension is still installed to do it.
834
+ if (hasExtension && (inExtension || o.install.cliOnPath === null))
835
+ return { fail: `${tools} to repair it: ${inIde}. ${dropIt}` };
836
+ if (o.install.cliOnPath !== null)
837
+ return { fail: `${tools} to repair it: run '${setupAgentsCommand([o.agent], o.root)}'. ${dropIt}` };
838
+ // Neither is on this machine any more: nothing here can rewrite it.
839
+ return {
840
+ fail: `${tools} no vexp is installed on this machine to rewrite it: install the vexp extension in your IDE, or the CLI ('npm install -g vexp-cli'), and set ${o.agent} up with it; ` +
841
+ `or delete the entry if you no longer use ${o.agent} with vexp.`,
842
+ };
843
+ }
844
+ // Still there, but not in an install: what a `vexp setup` run through npx
845
+ // leaves behind, which is where doctor's own advice used to send the
846
+ // reader of this line.
847
+ const cache = [targets.command, targets.script].map((p) => (p ? npxCacheOf(p) : null)).find((c) => c !== null);
848
+ if (cache) {
849
+ return {
850
+ note: `this entry starts vexp out of an npx cache (${cache}), not out of an install: it stops working when that cache is cleaned.` +
851
+ (o.install.extensions.length > 0 && o.machineWide !== false && o.managed !== false
852
+ ? ` To hand it back to the extension: delete the entry, then run "vexp: Setup Agents" in ${ideName(o.install.extensions)} with ${o.agent} ticked.`
853
+ : ""),
854
+ };
855
+ }
856
+ // Still there, but in a folder an update has already superseded.
857
+ const folder = extensionFolderOf(inExtension);
858
+ if (folder) {
859
+ let names = [];
860
+ try {
861
+ names = readdir(folder.extensionsDir);
862
+ }
863
+ catch {
864
+ /* not listable: nothing to compare */
865
+ }
866
+ const newest = newestExtensionVersion(names);
867
+ if (newest && isOlderVersion(folder.version, newest)) {
868
+ return {
869
+ note: `this entry starts the vexp extension v${folder.version}, and v${newest} is installed beside it: if the IDE removes the older folder, the entry points at nothing and ${o.agent} loses vexp's tools. Run doctor again after the next IDE restart.`,
870
+ };
871
+ }
872
+ }
873
+ return {};
874
+ }
875
+ /**
876
+ * The line for a project with no .claude/settings.json.
877
+ *
878
+ * It is a finding only for someone who uses Claude Code here: vexp's entry
879
+ * in ~/.claude.json is machine-wide, so it is there for every project once
880
+ * one was set up, and a project the user answered "Not here" for is as they
881
+ * want it. For the others the remedy has to install the hooks, which a plain
882
+ * `vexp setup` does not (see setupAgentsRemedy).
883
+ */
884
+ export function claudeNotSetUpFinding(o) {
885
+ if (!o.mcpConfigured)
886
+ return { level: OK, message: "no .claude/settings.json (Claude Code not configured here)" };
887
+ const remedy = setupAgentsRemedy(o.install, ["Claude Code"], o.root);
888
+ if (o.decision === "no" || o.decision === "never") {
889
+ return {
890
+ level: OK,
891
+ message: `no .claude/settings.json: Claude Code was declined for this project ('${o.decision === "never" ? "Never" : "Not here"}' on record), so vexp installs no orientation hooks here. To set it up after all: ${remedy}.`,
892
+ };
893
+ }
894
+ // "gets vexp's tools here" only for an entry that can start: twenty lines
895
+ // above, the same run may have failed it for a path that is gone.
896
+ const effect = o.entryStarts === false ? "" : ", so Claude Code gets vexp's tools here and no per-prompt orientation";
897
+ return {
898
+ level: WARN,
899
+ message: `no .claude/settings.json, but vexp MCP is configured for Claude Code — the orientation hooks were never installed in this project${effect}.\n` +
900
+ ` to install them: ${remedy}.` +
901
+ (extensionOnly(o.install) ? "" : ` A plain 'vexp setup' leaves an agent it finds only on this machine alone until you pick it.`),
902
+ };
903
+ }
904
+ /**
905
+ * The git-hooks verdict, as data.
906
+ *
907
+ * Extracted because it was wrong in a way no test could catch: an overriding
908
+ * `core.hooksPath` was reported as a FAILURE claiming "the index does not
909
+ * refresh on commit/merge/checkout", and the remedy offered was to edit a
910
+ * file that moon, husky and lefthook all regenerate. The claim is untrue —
911
+ * the daemon watches the tree live and reconciles it against disk every five
912
+ * minutes — and the remedy was unusable. A user spent a round trip on it.
913
+ */
197
914
  export function gitHooksVerdict(hooksPath, repoRoot, installedCount,
198
915
  /** The first line of .vexp/git-hooks.declined, when this clone said no to the hooks. */
199
916
  declined) {
@@ -616,14 +1333,49 @@ export function codexUseHere(root, consent, exists = fs.existsSync) {
616
1333
  function codexUseUnknown(use) {
617
1334
  return !use.used && use.decision === undefined && (use.onMachine === true || use.answersUnreadable !== undefined);
618
1335
  }
619
- /** Inverse of the two TOML strings vexp writes: '...' literal, "..." basic. */
1336
+ /** A TOML string value: '...' literal or "..." basic, the two vexp writes,
1337
+ * with or without a comment after it (a hand-edited line may carry one). */
620
1338
  function parseTomlString(raw) {
621
- const literal = /^'([^']*)'$/.exec(raw);
1339
+ const literal = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
622
1340
  if (literal)
623
1341
  return literal[1];
624
- const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
1342
+ const basic = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
625
1343
  return basic ? basic[1].replace(/\\(["\\])/g, "$1") : undefined;
626
1344
  }
1345
+ /** A one-line TOML array of strings, in either quoting. vexp writes JSON's
1346
+ * (`["mcp"]`); a hand-written `['mcp']` is the same array to Codex, and
1347
+ * JSON.parse read it as no array at all. Undefined for anything else. */
1348
+ function parseTomlStringArray(raw) {
1349
+ try {
1350
+ // The array as vexp writes it, with JSON's own escapes.
1351
+ const list = JSON.parse(raw.trim());
1352
+ if (Array.isArray(list) && list.every((v) => typeof v === "string"))
1353
+ return list;
1354
+ }
1355
+ catch {
1356
+ /* not JSON: read it as TOML below */
1357
+ }
1358
+ const m = /^\[(.*)\]\s*(?:#.*)?$/.exec(raw.trim());
1359
+ if (!m)
1360
+ return undefined;
1361
+ const out = [];
1362
+ const item = /\s*(?:'([^']*)'|"((?:[^"\\]|\\.)*)")\s*(?:,|$)/y;
1363
+ let pos = 0;
1364
+ while (pos < m[1].length) {
1365
+ item.lastIndex = pos;
1366
+ const hit = item.exec(m[1]);
1367
+ if (!hit)
1368
+ break;
1369
+ out.push(hit[1] ?? hit[2].replace(/\\(["\\])/g, "$1"));
1370
+ pos = item.lastIndex;
1371
+ }
1372
+ return m[1].slice(pos).trim() === "" ? out : undefined;
1373
+ }
1374
+ /** The `args` of a [mcp_servers.vexp] section, when it is a one-line array. */
1375
+ function codexSectionArgs(section) {
1376
+ const args = /^\s*args\s*=\s*(\[.*)$/m.exec(section);
1377
+ return args ? parseTomlStringArray(args[1]) : undefined;
1378
+ }
627
1379
  /** The project a [mcp_servers.vexp] section names, if any. Same reading as
628
1380
  * the extension's codexStanzaPin: the env pin, --workspace, cwd, then the
629
1381
  * /ws/<hash>/ route of the http transport. */
@@ -632,17 +1384,11 @@ function codexSectionPin(section) {
632
1384
  const fromEnv = env ? parseTomlString(env[1]) : undefined;
633
1385
  if (fromEnv)
634
1386
  return { path: fromEnv };
635
- const args = /^\s*args\s*=\s*(\[.*\])\s*$/m.exec(section);
636
- if (args) {
637
- try {
638
- const list = JSON.parse(args[1]);
639
- const i = list.indexOf("--workspace");
640
- if (i >= 0 && typeof list[i + 1] === "string")
641
- return { path: list[i + 1] };
642
- }
643
- catch {
644
- /* not the array vexp writes */
645
- }
1387
+ const list = codexSectionArgs(section);
1388
+ if (list) {
1389
+ const i = list.indexOf("--workspace");
1390
+ if (i >= 0 && typeof list[i + 1] === "string")
1391
+ return { path: list[i + 1] };
646
1392
  }
647
1393
  const cwd = /^\s*cwd\s*=\s*(.+?)\s*$/m.exec(section);
648
1394
  const fromCwd = cwd ? parseTomlString(cwd[1]) : undefined;
@@ -651,6 +1397,11 @@ function codexSectionPin(section) {
651
1397
  const url = /^\s*url\s*=\s*"[^"]*\/ws\/([0-9a-f]+)\/mcp"/m.exec(section);
652
1398
  return url ? { hash: url[1] } : {};
653
1399
  }
1400
+ /** The program and the arguments a [mcp_servers.vexp] section starts. */
1401
+ function codexSectionCommand(section) {
1402
+ const command = /^\s*command\s*=\s*(.+?)\s*$/m.exec(section);
1403
+ return { command: command ? parseTomlString(command[1]) : undefined, args: codexSectionArgs(section) };
1404
+ }
654
1405
  /** Does a vexp-written [mcp_servers.vexp] section name `root`? */
655
1406
  function codexSectionPinnedTo(section, root) {
656
1407
  const pin = codexSectionPin(section);
@@ -1156,6 +1907,13 @@ export function workspaceRulesFindings(root, status) {
1156
1907
  * the same ledger: orientations the daemon finished after the hook client
1157
1908
  * (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
1158
1909
  * never read by the agent (field ledger, 3.1.3: up to 8 of 33).
1910
+ *
1911
+ * The WARN says what the count is and stops there. It used to end "long
1912
+ * prompts are slow on this index", a cause the ledger does not hold: a
1913
+ * 14-word prompt goes late behind a held index lock, and a late row is also
1914
+ * written for a long prompt the daemon skipped after two late ones (review
1915
+ * of a support ticket, 2026-10-08: 10 late of 27, cause not established).
1916
+ * The timings that separate the causes are in the report, so it points there.
1159
1917
  */
1160
1918
  export function ledgerFindings(ledger) {
1161
1919
  const out = [];
@@ -1172,9 +1930,11 @@ export function ledgerFindings(ledger) {
1172
1930
  });
1173
1931
  }
1174
1932
  if (late > 0) {
1933
+ const served = Number(ledger.hints_served) || 0;
1175
1934
  out.push({
1176
1935
  level: WARN,
1177
- message: `${late} orientation(s) in the last 7 days finished after the hook client's budget (3 s by default) and never reached the agent - long prompts are slow on this index; vexp savings shows the count`,
1936
+ message: `${late} orientation(s) in the last 7 days were recorded late, against ${served} served: not ready within the hook client's wait (3 s by default), or a long prompt skipped after two late ones in its session. A late one normally does not reach the agent.\n` +
1937
+ ` what made them late is not recorded here: 'doctor --report' writes the timings (the slowest calls in its usage ledger, the slow statements in its daemon log section)`,
1178
1938
  });
1179
1939
  }
1180
1940
  return out;
@@ -1264,6 +2024,16 @@ export function sessionsFindings(sessions, o) {
1264
2024
  else if (o.prompts7d > 0) {
1265
2025
  out.push({ level: OK, message: `no agent tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)` });
1266
2026
  }
2027
+ else if ((o.unoriented7d ?? 0) > 0) {
2028
+ // Since the seed gate a prompt the search found nothing solid for gets
2029
+ // nothing and is in no ledger number: twelve such prompts in a week
2030
+ // read here as the WARN below ("check its MCP config"), directly under
2031
+ // the line saying the hook had reached vexp twelve times.
2032
+ out.push({
2033
+ level: OK,
2034
+ message: `no agent tool calls in the last 4h — but the prompt hook is reaching vexp: ${o.unoriented7d} prompt(s) in the last 7 days were searched and not oriented (the hook line above says why); zero tool calls is normal`,
2035
+ });
2036
+ }
1267
2037
  else if (o.uptimeS > 600) {
1268
2038
  // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
1269
2039
  // the daemon was unreachable serves from its own in-process index and
@@ -1300,6 +2070,15 @@ export function sessionsFindings(sessions, o) {
1300
2070
  * `hook_activity` (7 days) as doctor lines, apart from the savings ledger,
1301
2071
  * whose numbers these abstentions never change. Nothing for a daemon that
1302
2072
  * predates the key.
2073
+ *
2074
+ * Since the seed gate two more kinds of prompt get nothing: a typed prompt
2075
+ * the search found nothing for that passed the gate (it used to get the
2076
+ * one-line notice, or a seed on thin evidence, and "prompts analyzed" then
2077
+ * counted it), and a prompt whose one-line notice the session had already
2078
+ * received. Without them here a user who upgrades sees the ledger's count
2079
+ * fall and no line that says where the prompts went. The gate's third kind
2080
+ * is no abstention: a definition list served alone, with the seed switched
2081
+ * off, is in "hints served", and the aside says how many of those it was.
1303
2082
  */
1304
2083
  export function hookActivityFindings(ha, ledger, nowS) {
1305
2084
  if (!ha || typeof ha !== "object" || Array.isArray(ha))
@@ -1310,21 +2089,45 @@ export function hookActivityFindings(ha, ledger, nowS) {
1310
2089
  const empty = count(h.abstained_empty_index);
1311
2090
  // Absent from a daemon that predates the key: zero.
1312
2091
  const agentText = count(h.abstained_agent_text);
1313
- const skipped = short + empty + agentText;
2092
+ const unanchored = count(h.abstained_unanchored);
2093
+ const noticeHad = count(h.abstained_hintcap);
2094
+ // The first three were never searched: skipped. The gate's two were
2095
+ // classified and searched, and then nothing was sent: "skipped" is not
2096
+ // what happened to them, so a line that holds any says "not oriented".
2097
+ const unoriented = unanchored + noticeHad;
2098
+ const skipped = short + empty + agentText + unoriented;
1314
2099
  const analyzed = count(ledger.prompts_analyzed);
1315
2100
  const parts = [
1316
2101
  short > 0 ? `${short} under 8 words (follow-ups are never oriented)` : "",
1317
2102
  empty > 0 ? `${empty} while the index was empty` : "",
1318
2103
  agentText > 0 ? `${agentText} that were the agent's own text, nothing typed (a task notice, an approval)` : "",
2104
+ unanchored > 0 ? `${unanchored} typed and searched with nothing found that passed the seed gate (no orientation spent)` : "",
2105
+ noticeHad > 0 ? `${noticeHad} that would have repeated the one-line notice (sent once per session)` : "",
1319
2106
  ]
1320
2107
  .filter(Boolean)
1321
2108
  .join(", ");
1322
2109
  const out = [];
1323
2110
  if (skipped > 0 && analyzed === 0) {
1324
- out.push({ level: OK, message: `orientation hook reached vexp ${skipped} time(s) in the last ${days} days and every prompt was skipped: ${parts}` });
2111
+ const what = unoriented > 0 ? "no prompt was oriented" : "every prompt was skipped";
2112
+ out.push({ level: OK, message: `orientation hook reached vexp ${skipped} time(s) in the last ${days} days and ${what}: ${parts}` });
1325
2113
  }
1326
2114
  else if (skipped > 0) {
1327
- out.push({ level: NOTE, message: `the hook also skipped ${skipped} prompt(s) in the last ${days} days: ${parts}` });
2115
+ const what = unoriented === 0
2116
+ ? `skipped ${skipped} prompt(s)`
2117
+ : unoriented === skipped
2118
+ ? `left ${skipped} prompt(s) unoriented`
2119
+ : `skipped or left unoriented ${skipped} prompt(s)`;
2120
+ out.push({ level: NOTE, message: `the hook also ${what} in the last ${days} days: ${parts}` });
2121
+ }
2122
+ // Past tense: the count is of the window, not of today's config. "The
2123
+ // seed is switched off" was printed for seven days after the key had
2124
+ // been set for one afternoon and removed.
2125
+ const definitionLists = count(h.evidence_alone);
2126
+ if (definitionLists > 0) {
2127
+ out.push({
2128
+ level: NOTE,
2129
+ message: `${definitionLists} of the hints served in the last ${days} days were a definition list alone, served while the seed was switched off (ambient_seed = false or VEXP_AMBIENT_SEED=0)`,
2130
+ });
1328
2131
  }
1329
2132
  const last = typeof h.last_at === "number" && Number.isFinite(h.last_at) ? h.last_at : null;
1330
2133
  if (last !== null) {
@@ -1391,7 +2194,7 @@ export function pathRelation(a, b, platform) {
1391
2194
  return "unrelated";
1392
2195
  }
1393
2196
  const HOOK_OUTCOME_TROUBLE = {
1394
- "no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation ('vexp daemon-cmd start' starts one)",
2197
+ "no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation",
1395
2198
  timeout: "the daemon did not answer within the hook client's budget (3 s by default), so that prompt got no orientation",
1396
2199
  error: "the daemon answered with an error, so that prompt got no orientation; .vexp/daemon.log has the details",
1397
2200
  };
@@ -1405,8 +2208,10 @@ const HOOK_OUTCOME_TROUBLE = {
1405
2208
  * `neverServed` are folders no daemon can ever serve (the home directory,
1406
2209
  * the vexp home): a user-level hook (ZCode's, Codex's) meets no daemon there
1407
2210
  * every time a session opens in them, which says nothing about this project.
2211
+ * `startsOne` is what starts a daemon for the reader's install: the command,
2212
+ * unless they have the extension and no `vexp` to run it with.
1408
2213
  */
1409
- export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = []) {
2214
+ export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = [], startsOne = "'vexp daemon-cmd start' starts one") {
1410
2215
  const roots = [wsRoot, ...aliases.filter((a) => !!a)];
1411
2216
  const relation = (e) => {
1412
2217
  const rels = roots.map((r) => pathRelation(e.root, r, platform));
@@ -1416,7 +2221,8 @@ export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [],
1416
2221
  const out = [];
1417
2222
  const here = entries.filter((e) => relation(e) === "same").sort((a, b) => b.at - a.at)[0];
1418
2223
  if (here) {
1419
- const trouble = HOOK_OUTCOME_TROUBLE[here.outcome];
2224
+ const what = HOOK_OUTCOME_TROUBLE[here.outcome];
2225
+ const trouble = what && here.outcome === "no-daemon" ? `${what} (${startsOne})` : what;
1420
2226
  const reason = here.reason ? `: ${here.reason.slice(0, 200)}` : "";
1421
2227
  out.push({
1422
2228
  level: trouble ? WARN : OK,
@@ -2040,15 +2846,155 @@ export function buildDoctorReport(i) {
2040
2846
  parts.push("");
2041
2847
  return maskSecretsForReport(parts.join("\n"));
2042
2848
  }
2849
+ // The timestamp and level a daemon log line starts with, as the engine's
2850
+ // human format writes them (`2026-10-08T10:16:38.645986Z WARN message`).
2851
+ const LOG_STAMP = String.raw `^\s*(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+`;
2852
+ const LOG_LINE = new RegExp(`${LOG_STAMP}(TRACE|DEBUG|INFO|WARN|ERROR)\\b`);
2853
+ // The records that carry a prompt: the engine logs the query of every
2854
+ // capsule at INFO, as the user typed it, newlines included (mcp/capsule.rs,
2855
+ // the same four lines from 2.7.0 to 3.3.3), and a daemon started at the
2856
+ // debug level logs it in three more (mcp/pipeline.rs, memory/search.rs).
2857
+ // Each closes with a tail of its own, but for one: "No pivot nodes found"
2858
+ // ends where the query ends, and always comes after "Generating capsule"
2859
+ // for the same query. `loose` is a tail too plain to be looked for outside
2860
+ // its record (the closing quote of 80 bytes of the task).
2861
+ const promptRecord = (level, opens) => new RegExp(`${LOG_STAMP}${level}\\s+${opens}`);
2862
+ const PROMPT_RECORDS = [
2863
+ { start: promptRecord("INFO", "Generating capsule for query: "), end: / \[intent=[^\]]*, budget=\d+\]\s*$/ },
2864
+ { start: promptRecord("INFO", "Pivot threshold yielded 0 hits for query '"), end: /'; promoting top \d+ code BM25 candidate\(s\)\s*$/ },
2865
+ { start: promptRecord("INFO", "Only doc/data nodes matched code-action query '"), end: /'; returning honest empty capsule\s*$/ },
2866
+ { start: promptRecord("INFO", "No pivot nodes found for query: ") },
2867
+ { start: promptRecord("DEBUG", "T5 query expansion: '"), end: /' -> \[.*\]\s*$/ },
2868
+ { start: promptRecord("DEBUG", "Memory search for '"), end: /': \d+ candidates, \d+ results\s*$/ },
2869
+ { start: promptRecord("DEBUG", "B\\.15 preprocessor: running for task '"), end: /'\s*$/, loose: true },
2870
+ ];
2871
+ const PROMPT_RECORD_ENDS = PROMPT_RECORDS.flatMap((r) => (r.end && !r.loose ? [r.end] : []));
2872
+ // A Rust panic's first line: the thread and where in the source, no text.
2873
+ // The toolchain the engine is built with prints the thread's id after its
2874
+ // name (`thread 'main' (1692879) panicked at`); older ones did not.
2875
+ const PANIC_LINE = /^thread '[^']*'(?: \(\d+\))? panicked at /;
2876
+ // SQLite quotes back the piece of a full-text query it could not read, and
2877
+ // for a capsule's search that piece is a word of the prompt.
2878
+ const SQLITE_ECHO = /(error returned from database: \(code: \d+\) (?:unknown special query: |fts5: syntax error near ))(.+)$/;
2879
+ // The engine now leaves that piece out of its log itself, and writes how
2880
+ // long it was in its place (log_text.rs: "(11 chars of the query, not
2881
+ // logged)"). A tail that reads so is the engine's note, not a word of the
2882
+ // prompt: wrapped again it came out as "(35 chars of the query, not
2883
+ // quoted)", the length of the note.
2884
+ const SQLITE_ECHO_LEFT_OUT = /^\(\d+ chars of the query, not logged\)/;
2885
+ const LOG_VALUE_MAX = 120;
2886
+ const LOG_LINE_MAX = 600;
2887
+ /** A WARN or ERROR line cut to what a reader needs: a long quoted value (the
2888
+ * SQL of a slow statement runs to 700 characters) is shortened where it
2889
+ * stands, so the fields after it, the timings, are kept. */
2890
+ function shortenLogLine(line) {
2891
+ const cut = line
2892
+ .replace(SQLITE_ECHO, (whole, head, echoed) => SQLITE_ECHO_LEFT_OUT.test(echoed) ? whole : `${head}(${echoed.length} chars of the query, not quoted)`)
2893
+ .replace(/="((?:[^"\\]|\\.)*)"/g, (whole, value) => value.length > LOG_VALUE_MAX ? `="${value.slice(0, LOG_VALUE_MAX)}... (+${value.length - LOG_VALUE_MAX} chars)"` : whole);
2894
+ return cut.length > LOG_LINE_MAX ? `${cut.slice(0, LOG_LINE_MAX)}... (+${cut.length - LOG_LINE_MAX} chars)` : cut;
2895
+ }
2896
+ /** The level of a line of the engine's JSON log format (VEXP_LOG_FORMAT=json:
2897
+ * one object per record, with `timestamp` and an upper-case `level`), or
2898
+ * undefined for any other line, a JSON line of someone else's log included. */
2899
+ function jsonLogLevel(line) {
2900
+ if (!line.startsWith("{"))
2901
+ return undefined;
2902
+ try {
2903
+ const rec = JSON.parse(line);
2904
+ return typeof rec?.level === "string" && typeof rec.timestamp === "string" ? rec.level : undefined;
2905
+ }
2906
+ catch {
2907
+ return undefined;
2908
+ }
2909
+ }
2043
2910
  /**
2044
2911
  * Warnings and errors from the daemon log tail, ANSI stripped, newest last.
2045
2912
  * Repeats of one message (the same config warning on every start) collapse
2046
2913
  * into the latest occurrence with a count: a report is read by a person,
2047
2914
  * and eighty identical lines hide the one that differs.
2915
+ *
2916
+ * Only lines the daemon itself logged at WARN or ERROR, and the first line
2917
+ * of a panic. The filter used to keep any line that CONTAINED one of those
2918
+ * words, and the daemon log holds every oriented prompt in full: a prompt
2919
+ * with a pasted error log put its lines in a report whose header says it
2920
+ * never carries prompt text (review of a support ticket, 2026-10-08: 10 of
2921
+ * the 11 lines quoted from a test log were lines of a prompt). A pasted log
2922
+ * can itself be in the engine's format, so the lines of a prompt record are
2923
+ * skipped whatever they look like.
2924
+ *
2925
+ * Where a prompt ends cannot always be read off the log, and wherever it
2926
+ * cannot the line is left out: a report that misses a warning is a smaller
2927
+ * fault than one that quotes a prompt. So the log is read from its top (a
2928
+ * record that began above the lines quoted is still a record), a record
2929
+ * runs to its closing tail however long it is, and three more rules cover
2930
+ * what a first version let through (review of the fix, 2026-10-08):
2931
+ * - a line seen inside a prompt is never quoted afterwards, which is what
2932
+ * covers the record with no tail;
2933
+ * - a closing tail with no record open means the record was closed early
2934
+ * by a tail pasted inside the prompt (a piece of vexp's own log), and
2935
+ * everything quoted since that record began is taken back;
2936
+ * - a record that never closes (a line cut short, or an engine that words
2937
+ * it differently) stops at the next record logged after it, and when
2938
+ * there is none the count of lines left out is said.
2048
2939
  */
2049
2940
  export function logWarningsFrom(text, keep = 80, scan = 3000) {
2050
- const lines = stripAnsi(text).trimEnd().split("\n").slice(-scan);
2051
- const hits = lines.filter((l) => /\b(WARN|ERROR|panicked)\b/.test(l));
2941
+ const lines = stripAnsi(text).trimEnd().split("\n").map((l) => l.replace(/\r$/, ""));
2942
+ const from = Math.max(0, lines.length - scan);
2943
+ const hits = [];
2944
+ const promptLines = new Set();
2945
+ const remember = (l) => {
2946
+ promptLines.add(l);
2947
+ // The same line closes one record and is plain text in the next.
2948
+ for (const end of PROMPT_RECORD_ENDS)
2949
+ if (end.test(l))
2950
+ promptLines.add(l.replace(end, ""));
2951
+ };
2952
+ // The record being read: its closing tail, when it was logged, where.
2953
+ let open = null;
2954
+ // The latest record by its own timestamp, with what had been quoted when
2955
+ // it began. Pasted records carry older times and do not move it.
2956
+ let latest = "";
2957
+ let mark = 0;
2958
+ let since = 0;
2959
+ for (let i = 0; i < lines.length; i++) {
2960
+ const l = lines[i];
2961
+ const rec = PROMPT_RECORDS.find((r) => r.start.test(l));
2962
+ const at = rec ? rec.start.exec(l)[1] : "";
2963
+ if (open) {
2964
+ if (!(rec && at > open.at)) {
2965
+ remember(l);
2966
+ if (open.end.test(l))
2967
+ open = null;
2968
+ continue;
2969
+ }
2970
+ open = null; // a later record began: the one being read never closed
2971
+ }
2972
+ if (rec) {
2973
+ if (at >= latest) {
2974
+ latest = at;
2975
+ mark = hits.length;
2976
+ since = i + 1;
2977
+ }
2978
+ if (rec.end && !rec.end.test(l))
2979
+ open = { end: rec.end, at, line: i };
2980
+ continue;
2981
+ }
2982
+ if (promptLines.has(l))
2983
+ continue;
2984
+ if (PROMPT_RECORD_ENDS.some((end) => end.test(l))) {
2985
+ hits.length = mark;
2986
+ for (let j = since; j <= i; j++)
2987
+ remember(lines[j]);
2988
+ continue;
2989
+ }
2990
+ if (i < from)
2991
+ continue;
2992
+ const level = LOG_LINE.exec(l)?.[2] ?? jsonLogLevel(l);
2993
+ if (level === "WARN" || level === "ERROR")
2994
+ hits.push(shortenLogLine(l));
2995
+ else if (PANIC_LINE.test(l))
2996
+ hits.push(l.slice(0, LOG_LINE_MAX));
2997
+ }
2052
2998
  const byMessage = new Map();
2053
2999
  for (const l of hits) {
2054
3000
  const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
@@ -2062,9 +3008,27 @@ export function logWarningsFrom(text, keep = 80, scan = 3000) {
2062
3008
  else
2063
3009
  byMessage.set(key, { last: l, count: 1 });
2064
3010
  }
2065
- return [...byMessage.values()]
3011
+ const out = [...byMessage.values()]
2066
3012
  .map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
2067
3013
  .slice(-keep);
3014
+ const unread = open ? lines.length - 1 - Math.max(open.line, from - 1) : 0;
3015
+ if (unread > 0)
3016
+ out.push(`(${unread} line(s) at the end of the log were not read: they follow a logged prompt whose end was not found)`);
3017
+ return out;
3018
+ }
3019
+ /**
3020
+ * The daemon log the report quotes: the one written last. `daemon.log` is
3021
+ * the CLI-started daemon's and `vexp.log` the IDE extension's; with both in
3022
+ * .vexp the report took `daemon.log` whatever its age, so an extension
3023
+ * user who had once started a daemon from a terminal sent the warnings of
3024
+ * that old run under the heading of today's (review, 2026-10-08).
3025
+ */
3026
+ export function newestLogName(mtimes) {
3027
+ let best = null;
3028
+ for (const m of mtimes)
3029
+ if (!best || m.mtimeMs > best.mtimeMs)
3030
+ best = m;
3031
+ return best ? best.name : null;
2068
3032
  }
2069
3033
  /** The home folders iCloud's "Desktop & Documents Folders" syncs on this
2070
3034
  * Mac: the ones com~apple~CloudDocs holds a symlink to. Sync off, no link. */
@@ -2131,15 +3095,23 @@ export async function writeDoctorReport(o) {
2131
3095
  ledgerMarkdown = String(ledger.stdout);
2132
3096
  let logName = null;
2133
3097
  let logWarnings = [];
3098
+ const logs = [];
2134
3099
  for (const name of ["daemon.log", "vexp.log"]) {
2135
3100
  try {
2136
- const text = fs.readFileSync(path.join(o.root, ".vexp", name), "utf-8");
2137
- logName = name;
2138
- logWarnings = logWarningsFrom(text);
2139
- break;
3101
+ logs.push({ name, mtimeMs: fs.statSync(path.join(o.root, ".vexp", name)).mtimeMs });
2140
3102
  }
2141
3103
  catch {
2142
- /* try the next name */
3104
+ /* no such log */
3105
+ }
3106
+ }
3107
+ const newest = newestLogName(logs);
3108
+ if (newest) {
3109
+ try {
3110
+ logWarnings = logWarningsFrom(fs.readFileSync(path.join(o.root, ".vexp", newest), "utf-8"));
3111
+ logName = newest;
3112
+ }
3113
+ catch {
3114
+ /* unreadable: the report says no log was found */
2143
3115
  }
2144
3116
  }
2145
3117
  let configText = null;
@@ -2236,6 +3208,16 @@ async function doctorChecks(onWorkspace) {
2236
3208
  line(f.level, f.message);
2237
3209
  };
2238
3210
  console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
3211
+ // Which install the reader has decides every remedy below (see
3212
+ // InstallContext): read once.
3213
+ const install = installContextHere();
3214
+ {
3215
+ const origin = npxOriginNote(CLI_VERSION, install.npxCache);
3216
+ if (origin) {
3217
+ line(INFO, origin);
3218
+ console.log("");
3219
+ }
3220
+ }
2239
3221
  // 1) Workspace resolution for the current directory.
2240
3222
  console.log(chalk.bold("Workspace targeting"));
2241
3223
  const resolved = resolveWorkspace();
@@ -2264,9 +3246,29 @@ async function doctorChecks(onWorkspace) {
2264
3246
  }
2265
3247
  const sock = socketForWorkspace(ws.root);
2266
3248
  const live = await reachable(sock);
2267
- line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
2268
- if (!live)
2269
- console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
3249
+ // No daemon, the extension and no `vexp` command: most often a project
3250
+ // that is simply not open in the IDE, which is no fault at all.
3251
+ let closedInIde = false;
3252
+ if (live)
3253
+ line(OK, `daemon socket reachable: ${sock}`);
3254
+ else {
3255
+ let startBlocked;
3256
+ try {
3257
+ startBlocked = fs.readFileSync(path.join(ws.root, ".vexp", "start-blocked"), "utf-8");
3258
+ }
3259
+ catch { /* the last start was not refused */ }
3260
+ const what = notAProject(ws.root);
3261
+ const down = daemonDownFindings({
3262
+ sock,
3263
+ install,
3264
+ hasIndex: fs.existsSync(path.join(ws.root, ".vexp", "index.db")),
3265
+ startBlocked,
3266
+ outside: what ? { root: ws.root, what } : undefined,
3267
+ });
3268
+ closedInIde = down.every((f) => f.level === INFO);
3269
+ for (const f of down)
3270
+ emit(f);
3271
+ }
2270
3272
  // The /tmp relocation is silent by construction — every client computes
2271
3273
  // the same fallback and none of them says so. A tester measured his
2272
3274
  // Claude Code worktrees at 89 of the 100 characters and pointed out that
@@ -2332,15 +3334,28 @@ async function doctorChecks(onWorkspace) {
2332
3334
  const { execFileSync } = await import("node:child_process");
2333
3335
  const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
2334
3336
  const bundled = out.trim().split(/\s+/).pop();
2335
- const running = st.daemon_version;
2336
- if (bundled && running && bundled !== running) {
2337
- const exe = st.daemon_exe;
2338
- line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version.\n ${staleDaemonRemedy(exe)}`);
2339
- }
2340
- else if (st.binary_stale === true) {
2341
- const exe = st.daemon_exe;
2342
- line(WARN, `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(exe)}`);
3337
+ const exe = typeof st.daemon_exe === "string" ? st.daemon_exe : undefined;
3338
+ // What else is installed beside the extension folder the daemon
3339
+ // runs from: a newer one there means the window was not reloaded.
3340
+ let siblings = [];
3341
+ const folder = extensionFolderOf(exe);
3342
+ if (folder) {
3343
+ try {
3344
+ siblings = fs.readdirSync(folder.extensionsDir);
3345
+ }
3346
+ catch { /* not listable from here (another user's home, a remote) */ }
2343
3347
  }
3348
+ const stale = daemonVersionFinding({
3349
+ running: typeof st.daemon_version === "string" ? st.daemon_version : undefined,
3350
+ engine: bundled,
3351
+ exe,
3352
+ binaryStale: st.binary_stale === true,
3353
+ cliVersion: CLI_VERSION,
3354
+ npxCache: install.npxCache,
3355
+ siblings,
3356
+ });
3357
+ if (stale)
3358
+ emit(stale);
2344
3359
  }
2345
3360
  catch { /* best-effort */ }
2346
3361
  // 2.3 C1 — which agent sessions actually used vexp (a session with zero
@@ -2360,10 +3375,12 @@ async function doctorChecks(onWorkspace) {
2360
3375
  emit(f);
2361
3376
  // Agent sessions only: the hook's pseudo-session and one-shot CLI
2362
3377
  // calls were counted as "active" sessions (field report, 2026-09-28).
2363
- const hookLastAt = st.hook_activity?.last_at;
3378
+ const hookActivity = (st.hook_activity ?? {});
3379
+ const hookLastAt = hookActivity.last_at;
2364
3380
  for (const f of sessionsFindings(sessions, {
2365
3381
  uptimeS: Number(st.daemon_uptime_s) || 0,
2366
3382
  prompts7d: analyzed,
3383
+ unoriented7d: count(hookActivity.abstained_unanchored) + count(hookActivity.abstained_hintcap),
2367
3384
  hookLastAt: typeof hookLastAt === "number" ? hookLastAt : null,
2368
3385
  nowS: Math.floor(Date.now() / 1000),
2369
3386
  }))
@@ -2386,7 +3403,8 @@ async function doctorChecks(onWorkspace) {
2386
3403
  }
2387
3404
  catch { /* the root as given */ }
2388
3405
  const neverServed = [os.homedir(), vexpHome()];
2389
- for (const f of hookCallFindings(parseHookCalls(text), ws.root, Math.floor(Date.now() / 1000), process.platform, aliases, neverServed))
3406
+ const startsOne = extensionOnly(install) ? `the vexp extension starts one when the project's window opens in ${ideName(install.extensions)}` : undefined;
3407
+ for (const f of hookCallFindings(parseHookCalls(text), ws.root, Math.floor(Date.now() / 1000), process.platform, aliases, neverServed, startsOne))
2390
3408
  emit(f);
2391
3409
  }
2392
3410
  }
@@ -2438,7 +3456,11 @@ async function doctorChecks(onWorkspace) {
2438
3456
  }
2439
3457
  }
2440
3458
  const inReg = entries.some(([r]) => samePath(r, ws.root));
2441
- if (!inReg)
3459
+ // A project with no daemon has no row: said once, above, when that is
3460
+ // its ordinary state.
3461
+ if (!inReg && closedInIde)
3462
+ line(INFO, `current workspace (${ws.root}) is not registered: no daemon is running for it (see 'Workspace targeting' above)`);
3463
+ else if (!inReg)
2442
3464
  line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
2443
3465
  // Multi-daemon + global pin: catches the case the section-1 check misses
2444
3466
  // (doctor run from the same dir VEXP_WORKSPACE points at). With several
@@ -2518,11 +3540,19 @@ async function doctorChecks(onWorkspace) {
2518
3540
  // Whether this project chose Codex decides every Codex remedy below: a
2519
3541
  // user who did not is never sent to `vexp setup`, which on 3.2.5 set Codex
2520
3542
  // up again from its own leftovers.
2521
- const codexUse = codexUseHere(ws.root);
3543
+ // The machine-wide file is never the project's own, also when doctor is
3544
+ // run from the home folder and the two paths are one: read as the
3545
+ // project's, it made the home "a project set up for Codex".
3546
+ const codexGlobalConfig = path.join(os.homedir(), ".codex", "config.toml");
3547
+ const codexUse = codexUseHere(ws.root, undefined, (p) => fs.existsSync(p) && !samePath(p, codexGlobalConfig));
3548
+ // The machine-wide file, then the project's own. From the home folder the
3549
+ // two are one file: read as the project's it was printed twice, the
3550
+ // second time as a file "vexp does not write" (review of the fix,
3551
+ // 2026-10-08).
2522
3552
  const codexConfigs = [
2523
- { label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
2524
- { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
2525
- ];
3553
+ { label: "~/.codex/config.toml", file: codexGlobalConfig, machineWide: true },
3554
+ { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml"), machineWide: false },
3555
+ ].filter((c, i, all) => i === 0 || !samePath(c.file, all[0].file));
2526
3556
  let codexStanzaSeen = false;
2527
3557
  // An entry Codex can start: the hook verdict below says "the tools work"
2528
3558
  // only after one (not after a url + command one, which Codex refuses), and
@@ -2532,6 +3562,9 @@ async function doctorChecks(onWorkspace) {
2532
3562
  // 2026-09-24).
2533
3563
  let codexMcpUsable = false;
2534
3564
  let codexMcpElsewhere;
3565
+ // An entry on the http transport: the one client of the HTTP MCP server
3566
+ // that vexp itself sets up (section 6 says who talks to that server).
3567
+ let codexHttp = false;
2535
3568
  const codexEntryHere = (section) => {
2536
3569
  const pin = codexSectionPin(section);
2537
3570
  if ((pin.path === undefined && pin.hash === undefined) || codexSectionPinnedTo(section, ws.root))
@@ -2539,7 +3572,7 @@ async function doctorChecks(onWorkspace) {
2539
3572
  codexMcpElsewhere ??= pin.path ?? "another project";
2540
3573
  return false;
2541
3574
  };
2542
- for (const { label, file } of codexConfigs) {
3575
+ for (const { label, file, machineWide } of codexConfigs) {
2543
3576
  if (!fs.existsSync(file)) {
2544
3577
  line(OK, `${label}: absent`);
2545
3578
  continue;
@@ -2564,6 +3597,7 @@ async function doctorChecks(onWorkspace) {
2564
3597
  else if (hasUrl) {
2565
3598
  if (codexEntryHere(section))
2566
3599
  codexMcpUsable = true;
3600
+ codexHttp = true;
2567
3601
  line(OK, `${label}: transport http (url)`);
2568
3602
  // The URL routes to one workspace's daemon, so this form cannot follow
2569
3603
  // the session's folder the way the direct one does.
@@ -2573,12 +3607,31 @@ async function doctorChecks(onWorkspace) {
2573
3607
  }
2574
3608
  else if (hasCmd) {
2575
3609
  const here = codexEntryHere(section);
2576
- if (here)
3610
+ // The entry is only as good as the program it names: one left pointing
3611
+ // into an extension folder an update removed starts nothing.
3612
+ const started = codexSectionCommand(section);
3613
+ const entry = machineEntryCheck({
3614
+ agent: "Codex",
3615
+ command: started.command,
3616
+ args: started.args,
3617
+ install,
3618
+ root: ws.root,
3619
+ machineWide,
3620
+ // A section without the marker is the user's: setup and the
3621
+ // extension both leave it as it is (isUserManagedCodexSection).
3622
+ managed: section.includes("# vexp-managed"),
3623
+ });
3624
+ if (here && !entry.fail)
2577
3625
  codexMcpUsable = true;
2578
3626
  const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
2579
3627
  const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
2580
3628
  const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
2581
- line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin: the server starts in each Codex session's folder"}`);
3629
+ if (entry.fail)
3630
+ line(BAD, `${label}: ${entry.fail}`);
3631
+ else
3632
+ line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin: the server starts in each Codex session's folder"}`);
3633
+ if (entry.note)
3634
+ emit({ level: NOTE, message: entry.note });
2582
3635
  const pin = codexSectionPin(section).path;
2583
3636
  if (!here && pin !== undefined && section.includes("# vexp-managed"))
2584
3637
  line(WARN, codexStdioPinNote(section, pin, ws.root));
@@ -2592,15 +3645,28 @@ async function doctorChecks(onWorkspace) {
2592
3645
  // 5) Claude Code entry (should be UNPINNED after the multi-session fix).
2593
3646
  console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
2594
3647
  const claudePath = path.join(os.homedir(), ".claude.json");
3648
+ // Whether the entry can start what it names: the hooks section below
3649
+ // speaks of "vexp's tools" only when it can.
3650
+ let claudeEntryStarts = true;
2595
3651
  try {
2596
3652
  const cfg = JSON.parse(fs.readFileSync(claudePath, "utf-8"));
2597
3653
  const v = cfg?.mcpServers?.vexp;
2598
3654
  if (!v)
2599
3655
  line(OK, "no user-scope vexp entry");
2600
- else if (v.env?.VEXP_WORKSPACE)
2601
- line(WARN, `vexp entry still pins VEXP_WORKSPACE=${v.env.VEXP_WORKSPACE} → run 'vexp setup' to de-pin (multi-session fix)`);
2602
- else
2603
- line(OK, "vexp entry present, no VEXP_WORKSPACE pin (per-session targeting OK)");
3656
+ else {
3657
+ // Present is not the same as startable (see machineEntryCheck).
3658
+ const entry = machineEntryCheck({ agent: "Claude Code", command: v.command, args: v.args, install, root: ws.root });
3659
+ if (entry.fail) {
3660
+ claudeEntryStarts = false;
3661
+ line(BAD, entry.fail);
3662
+ }
3663
+ if (v.env?.VEXP_WORKSPACE)
3664
+ line(WARN, `vexp entry still pins VEXP_WORKSPACE=${v.env.VEXP_WORKSPACE} → run 'vexp setup' to de-pin (multi-session fix)`);
3665
+ else if (!entry.fail)
3666
+ line(OK, "vexp entry present, no VEXP_WORKSPACE pin (per-session targeting OK)");
3667
+ if (entry.note)
3668
+ emit({ level: NOTE, message: entry.note });
3669
+ }
2604
3670
  }
2605
3671
  catch {
2606
3672
  line(OK, "no ~/.claude.json");
@@ -2840,9 +3906,7 @@ async function doctorChecks(onWorkspace) {
2840
3906
  })();
2841
3907
  if (!settings) {
2842
3908
  // Only a finding for someone who actually uses Claude Code here.
2843
- line(mcpConfigured ? WARN : OK, mcpConfigured
2844
- ? "no .claude/settings.json, but vexp MCP is configured for Claude Code — the orientation hooks were never installed. Run 'vexp setup' (use --dry-run first to see what it would write)."
2845
- : "no .claude/settings.json (Claude Code not configured here)");
3909
+ emit(claudeNotSetUpFinding({ mcpConfigured, decision: decisionFor(readAgentConsent(), ws.root, "Claude Code"), install, root: ws.root, entryStarts: claudeEntryStarts }));
2846
3910
  }
2847
3911
  else {
2848
3912
  // Hooks Claude Code cannot run on this PC for want of Git Bash: one line
@@ -2858,7 +3922,7 @@ async function doctorChecks(onWorkspace) {
2858
3922
  line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
2859
3923
  }
2860
3924
  else {
2861
- line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
3925
+ line(WARN, `${w.event} (${w.label}) not installed — to write it: ${setupAgentsRemedy(install, ["Claude Code"], ws.root)}.`);
2862
3926
  }
2863
3927
  continue;
2864
3928
  }
@@ -3194,73 +4258,18 @@ async function doctorChecks(onWorkspace) {
3194
4258
  }
3195
4259
  }
3196
4260
  }
3197
- // A supervisor from an older install respawning the current bundle. It
3198
- // keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
3199
- // stop it: the old process runs the old comparison. The only fix is to end
3200
- // it, so doctor has to name it — with its pid, which the record now
3201
- // carries. A tester spent a night on a :7821 server that came back with a
3202
- // new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
3203
- // was not installed anywhere: the LABEL came from a `vexp serve` resident
3204
- // since before the upgrade, holding its own version in memory
3205
- // (field report, 2026-09-06).
3206
- const staleSupervisorLine = (rec) => {
3207
- const v = supervisorVerdict(rec, CLI_VERSION, {
3208
- parentOf: parentPid,
3209
- alive: isAlive,
3210
- });
3211
- if (v.kind === "ok")
3212
- return;
3213
- if (v.kind === "unknown") {
3214
- line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
3215
- ` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
3216
- return;
3217
- }
3218
- const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
3219
- const provenance = v.by
3220
- ? `running v${v.by}, not v${CLI_VERSION}`
3221
- : `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
3222
- line(WARN, `that server was spawned by a ${who} ${provenance}` +
3223
- (v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
3224
- `\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
3225
- // Ending it is the whole remediation: login autostart (or the next
3226
- // `vexp` command) starts a fresh supervisor, which reads the current
3227
- // package.json and serves the current build. The tester who did this
3228
- // had a correct server one second later without running anything
3229
- // else — and the `vexp setup` this line used to recommend blocks on
3230
- // an interactive prompt, which for the reader of a doctor line is a
3231
- // hang, not a fix (field report, 2026-09-07).
3232
- (v.alive
3233
- ? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
3234
- // The order is load-bearing and nothing said so: an old
3235
- // supervisor's health loop rebuilds the daemon registry every 60s
3236
- // from its own rows, so it deletes each daemon's registration as
3237
- // fast as the daemon writes it. Restart the daemons first and they
3238
- // come back invisible — and nobody restarts a daemon they have
3239
- // just restarted (field report, 2026-09-08).
3240
- `\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
3241
- `\n restarted before it is gone comes back invisible to 'vexp daemons'.`
3242
- : ""));
3243
- };
3244
- // 6) HTTP MCP supervisor.
4261
+ // 6) HTTP MCP supervisor. Every decision is in httpServerFindings.
3245
4262
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
3246
4263
  try {
3247
4264
  const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
3248
- const alive = isAlive(rec.pid);
3249
- line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
3250
- if (alive && rec.version === CLI_VERSION)
3251
- staleSupervisorLine(rec);
3252
- // The supervisor is replaced on version mismatch only when a vexp command
3253
- // next runs; until then every HTTP client (Codex http transport, a
3254
- // `serverUrl` entry) talks to the OLD build — which advertises the old
3255
- // tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
3256
- // saw two vexp servers with different tool counts and could not tell why.
3257
- if (alive && rec.version && rec.version !== CLI_VERSION) {
3258
- 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`);
3259
- staleSupervisorLine(rec);
3260
- }
3261
- else if (alive && !rec.version) {
3262
- 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}`);
3263
- }
4265
+ for (const f of httpServerFindings(rec, {
4266
+ cliVersion: CLI_VERSION,
4267
+ platform: process.platform,
4268
+ install,
4269
+ codexHttp,
4270
+ io: { parentOf: parentPid, alive: isAlive, commandLineOf: processCommandLine },
4271
+ }))
4272
+ emit(f);
3264
4273
  }
3265
4274
  catch {
3266
4275
  line(OK, "not running (no mcp.pid)");
@@ -3274,7 +4283,7 @@ async function doctorChecks(onWorkspace) {
3274
4283
  if (warns > 0 || fails > 0) {
3275
4284
  // The Codex half only where Codex was chosen: to anyone else it read as
3276
4285
  // one more reason to set Codex up (field report, 2026-09-24).
3277
- console.log(chalk.dim(` Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs${codexUse.used ? "; restart Codex to drop a cached config" : ""}.`));
4286
+ console.log(chalk.dim(` ${summaryTips(install, codexUse.used)}`));
3278
4287
  }
3279
4288
  console.log("");
3280
4289
  // Scripts and CI read the exit code, not the colors: any FAIL exits 1.