vexp-cli 3.3.2 → 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +187 -25
- package/dist/autostart.js +13 -5
- package/dist/cli.js +134 -11
- package/dist/daemon-upgrade.js +337 -0
- package/dist/doctor.js +1216 -148
- package/dist/mcp-supervisor.js +98 -7
- package/dist/stop-all.js +116 -0
- package/mcp/mcp-server.cjs +51 -48
- package/package.json +6 -6
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
|
|
142
|
+
* The HTTP MCP server section (~/.vexp/mcp.pid), as data.
|
|
142
143
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
|
169
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 = /^'([^']*)'
|
|
1339
|
+
const literal = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
|
|
622
1340
|
if (literal)
|
|
623
1341
|
return literal[1];
|
|
624
|
-
const basic = /^"((?:[^"\\]|\\.)*)"
|
|
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
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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);
|
|
@@ -946,6 +1697,60 @@ export function resolveMemberParent(root, registryRoots) {
|
|
|
946
1697
|
}
|
|
947
1698
|
return null;
|
|
948
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Entries of `.vexp/workspace.json` the daemon skips: one with no folder
|
|
1702
|
+
* behind it, and one that is a folder this workspace sits inside (serving
|
|
1703
|
+
* it would index every project beside this one). The daemon said so only in
|
|
1704
|
+
* its log, on every load of the config: a repository deleted without editing
|
|
1705
|
+
* workspace.json filled two thirds of a daemon log while doctor reported
|
|
1706
|
+
* the workspace healthy (field report, 2026-10-06). The log now says it
|
|
1707
|
+
* once, so this is where it is still said a day later.
|
|
1708
|
+
*/
|
|
1709
|
+
export function workspaceSkippedRepoFindings(root) {
|
|
1710
|
+
const configPath = path.join(root, ".vexp", "workspace.json");
|
|
1711
|
+
const realOf = (p) => {
|
|
1712
|
+
try {
|
|
1713
|
+
return fs.realpathSync.native(p);
|
|
1714
|
+
}
|
|
1715
|
+
catch {
|
|
1716
|
+
return p;
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
const isFolder = (p) => {
|
|
1720
|
+
try {
|
|
1721
|
+
return fs.statSync(p).isDirectory();
|
|
1722
|
+
}
|
|
1723
|
+
catch {
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
// Paths resolve as the daemon resolves them (`resolve_repo_path` in
|
|
1728
|
+
// vexp-core): against the workspace root, then against `.vexp/` (configs
|
|
1729
|
+
// written before 2.2.1). Against the real folder, so that ".." steps out
|
|
1730
|
+
// of it and not out of a link to it; and through `path.resolve`, so that
|
|
1731
|
+
// on Windows a path with no drive letter takes the workspace's drive.
|
|
1732
|
+
const real = realOf(root);
|
|
1733
|
+
// A folder the workspace is inside of: the daemon serves none (`"."`, the
|
|
1734
|
+
// workspace itself, is not one).
|
|
1735
|
+
const holdsTheWorkspace = (dir) => {
|
|
1736
|
+
const rel = path.relative(realOf(dir), real);
|
|
1737
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
|
|
1738
|
+
};
|
|
1739
|
+
const out = [];
|
|
1740
|
+
for (const r of listWorkspaceRepos(root)) {
|
|
1741
|
+
const folders = [path.resolve(real, r.rawPath), path.resolve(real, ".vexp", r.rawPath)].filter(isFolder);
|
|
1742
|
+
if (folders.some((dir) => !holdsTheWorkspace(dir)))
|
|
1743
|
+
continue;
|
|
1744
|
+
const where = `[${r.alias}] listed in ${configPath} at ${r.rawPath}`;
|
|
1745
|
+
out.push({
|
|
1746
|
+
level: WARN,
|
|
1747
|
+
message: folders.length > 0
|
|
1748
|
+
? `${where}, a folder this workspace is inside of — the daemon skips it: serving it would index every project beside this one. Point the entry at the repository itself or remove it.`
|
|
1749
|
+
: `${where}, but there is no folder there — the daemon skips it. Restore the folder or remove the entry.`,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
return out;
|
|
1753
|
+
}
|
|
949
1754
|
/**
|
|
950
1755
|
* Coverage verdicts for the workspace at `root` AND every repo connected to
|
|
951
1756
|
* it, each tagged with its alias. Each repo's indexer writes its own
|
|
@@ -1102,6 +1907,13 @@ export function workspaceRulesFindings(root, status) {
|
|
|
1102
1907
|
* the same ledger: orientations the daemon finished after the hook client
|
|
1103
1908
|
* (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
|
|
1104
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.
|
|
1105
1917
|
*/
|
|
1106
1918
|
export function ledgerFindings(ledger) {
|
|
1107
1919
|
const out = [];
|
|
@@ -1118,9 +1930,11 @@ export function ledgerFindings(ledger) {
|
|
|
1118
1930
|
});
|
|
1119
1931
|
}
|
|
1120
1932
|
if (late > 0) {
|
|
1933
|
+
const served = Number(ledger.hints_served) || 0;
|
|
1121
1934
|
out.push({
|
|
1122
1935
|
level: WARN,
|
|
1123
|
-
message: `${late} orientation(s) in the last 7 days
|
|
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)`,
|
|
1124
1938
|
});
|
|
1125
1939
|
}
|
|
1126
1940
|
return out;
|
|
@@ -1210,6 +2024,16 @@ export function sessionsFindings(sessions, o) {
|
|
|
1210
2024
|
else if (o.prompts7d > 0) {
|
|
1211
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)` });
|
|
1212
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
|
+
}
|
|
1213
2037
|
else if (o.uptimeS > 600) {
|
|
1214
2038
|
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
1215
2039
|
// the daemon was unreachable serves from its own in-process index and
|
|
@@ -1246,6 +2070,15 @@ export function sessionsFindings(sessions, o) {
|
|
|
1246
2070
|
* `hook_activity` (7 days) as doctor lines, apart from the savings ledger,
|
|
1247
2071
|
* whose numbers these abstentions never change. Nothing for a daemon that
|
|
1248
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.
|
|
1249
2082
|
*/
|
|
1250
2083
|
export function hookActivityFindings(ha, ledger, nowS) {
|
|
1251
2084
|
if (!ha || typeof ha !== "object" || Array.isArray(ha))
|
|
@@ -1254,20 +2087,47 @@ export function hookActivityFindings(ha, ledger, nowS) {
|
|
|
1254
2087
|
const days = count(h.period_days) || 7;
|
|
1255
2088
|
const short = count(h.abstained_short);
|
|
1256
2089
|
const empty = count(h.abstained_empty_index);
|
|
1257
|
-
|
|
2090
|
+
// Absent from a daemon that predates the key: zero.
|
|
2091
|
+
const agentText = count(h.abstained_agent_text);
|
|
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;
|
|
1258
2099
|
const analyzed = count(ledger.prompts_analyzed);
|
|
1259
2100
|
const parts = [
|
|
1260
2101
|
short > 0 ? `${short} under 8 words (follow-ups are never oriented)` : "",
|
|
1261
2102
|
empty > 0 ? `${empty} while the index was empty` : "",
|
|
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)` : "",
|
|
1262
2106
|
]
|
|
1263
2107
|
.filter(Boolean)
|
|
1264
2108
|
.join(", ");
|
|
1265
2109
|
const out = [];
|
|
1266
2110
|
if (skipped > 0 && analyzed === 0) {
|
|
1267
|
-
|
|
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}` });
|
|
1268
2113
|
}
|
|
1269
2114
|
else if (skipped > 0) {
|
|
1270
|
-
|
|
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
|
+
});
|
|
1271
2131
|
}
|
|
1272
2132
|
const last = typeof h.last_at === "number" && Number.isFinite(h.last_at) ? h.last_at : null;
|
|
1273
2133
|
if (last !== null) {
|
|
@@ -1334,7 +2194,7 @@ export function pathRelation(a, b, platform) {
|
|
|
1334
2194
|
return "unrelated";
|
|
1335
2195
|
}
|
|
1336
2196
|
const HOOK_OUTCOME_TROUBLE = {
|
|
1337
|
-
"no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation
|
|
2197
|
+
"no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation",
|
|
1338
2198
|
timeout: "the daemon did not answer within the hook client's budget (3 s by default), so that prompt got no orientation",
|
|
1339
2199
|
error: "the daemon answered with an error, so that prompt got no orientation; .vexp/daemon.log has the details",
|
|
1340
2200
|
};
|
|
@@ -1348,8 +2208,10 @@ const HOOK_OUTCOME_TROUBLE = {
|
|
|
1348
2208
|
* `neverServed` are folders no daemon can ever serve (the home directory,
|
|
1349
2209
|
* the vexp home): a user-level hook (ZCode's, Codex's) meets no daemon there
|
|
1350
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.
|
|
1351
2213
|
*/
|
|
1352
|
-
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") {
|
|
1353
2215
|
const roots = [wsRoot, ...aliases.filter((a) => !!a)];
|
|
1354
2216
|
const relation = (e) => {
|
|
1355
2217
|
const rels = roots.map((r) => pathRelation(e.root, r, platform));
|
|
@@ -1359,7 +2221,8 @@ export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [],
|
|
|
1359
2221
|
const out = [];
|
|
1360
2222
|
const here = entries.filter((e) => relation(e) === "same").sort((a, b) => b.at - a.at)[0];
|
|
1361
2223
|
if (here) {
|
|
1362
|
-
const
|
|
2224
|
+
const what = HOOK_OUTCOME_TROUBLE[here.outcome];
|
|
2225
|
+
const trouble = what && here.outcome === "no-daemon" ? `${what} (${startsOne})` : what;
|
|
1363
2226
|
const reason = here.reason ? `: ${here.reason.slice(0, 200)}` : "";
|
|
1364
2227
|
out.push({
|
|
1365
2228
|
level: trouble ? WARN : OK,
|
|
@@ -1983,15 +2846,155 @@ export function buildDoctorReport(i) {
|
|
|
1983
2846
|
parts.push("");
|
|
1984
2847
|
return maskSecretsForReport(parts.join("\n"));
|
|
1985
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
|
+
}
|
|
1986
2910
|
/**
|
|
1987
2911
|
* Warnings and errors from the daemon log tail, ANSI stripped, newest last.
|
|
1988
2912
|
* Repeats of one message (the same config warning on every start) collapse
|
|
1989
2913
|
* into the latest occurrence with a count: a report is read by a person,
|
|
1990
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.
|
|
1991
2939
|
*/
|
|
1992
2940
|
export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
1993
|
-
const lines = stripAnsi(text).trimEnd().split("\n").
|
|
1994
|
-
const
|
|
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
|
+
}
|
|
1995
2998
|
const byMessage = new Map();
|
|
1996
2999
|
for (const l of hits) {
|
|
1997
3000
|
const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
|
|
@@ -2005,9 +3008,27 @@ export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
|
2005
3008
|
else
|
|
2006
3009
|
byMessage.set(key, { last: l, count: 1 });
|
|
2007
3010
|
}
|
|
2008
|
-
|
|
3011
|
+
const out = [...byMessage.values()]
|
|
2009
3012
|
.map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
|
|
2010
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;
|
|
2011
3032
|
}
|
|
2012
3033
|
/** The home folders iCloud's "Desktop & Documents Folders" syncs on this
|
|
2013
3034
|
* Mac: the ones com~apple~CloudDocs holds a symlink to. Sync off, no link. */
|
|
@@ -2074,15 +3095,23 @@ export async function writeDoctorReport(o) {
|
|
|
2074
3095
|
ledgerMarkdown = String(ledger.stdout);
|
|
2075
3096
|
let logName = null;
|
|
2076
3097
|
let logWarnings = [];
|
|
3098
|
+
const logs = [];
|
|
2077
3099
|
for (const name of ["daemon.log", "vexp.log"]) {
|
|
2078
3100
|
try {
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
3101
|
+
logs.push({ name, mtimeMs: fs.statSync(path.join(o.root, ".vexp", name)).mtimeMs });
|
|
3102
|
+
}
|
|
3103
|
+
catch {
|
|
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;
|
|
2083
3112
|
}
|
|
2084
3113
|
catch {
|
|
2085
|
-
/*
|
|
3114
|
+
/* unreadable: the report says no log was found */
|
|
2086
3115
|
}
|
|
2087
3116
|
}
|
|
2088
3117
|
let configText = null;
|
|
@@ -2179,6 +3208,16 @@ async function doctorChecks(onWorkspace) {
|
|
|
2179
3208
|
line(f.level, f.message);
|
|
2180
3209
|
};
|
|
2181
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
|
+
}
|
|
2182
3221
|
// 1) Workspace resolution for the current directory.
|
|
2183
3222
|
console.log(chalk.bold("Workspace targeting"));
|
|
2184
3223
|
const resolved = resolveWorkspace();
|
|
@@ -2207,9 +3246,29 @@ async function doctorChecks(onWorkspace) {
|
|
|
2207
3246
|
}
|
|
2208
3247
|
const sock = socketForWorkspace(ws.root);
|
|
2209
3248
|
const live = await reachable(sock);
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
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
|
+
}
|
|
2213
3272
|
// The /tmp relocation is silent by construction — every client computes
|
|
2214
3273
|
// the same fallback and none of them says so. A tester measured his
|
|
2215
3274
|
// Claude Code worktrees at 89 of the 100 characters and pointed out that
|
|
@@ -2275,15 +3334,28 @@ async function doctorChecks(onWorkspace) {
|
|
|
2275
3334
|
const { execFileSync } = await import("node:child_process");
|
|
2276
3335
|
const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
|
|
2277
3336
|
const bundled = out.trim().split(/\s+/).pop();
|
|
2278
|
-
const
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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) */ }
|
|
2286
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);
|
|
2287
3359
|
}
|
|
2288
3360
|
catch { /* best-effort */ }
|
|
2289
3361
|
// 2.3 C1 — which agent sessions actually used vexp (a session with zero
|
|
@@ -2303,10 +3375,12 @@ async function doctorChecks(onWorkspace) {
|
|
|
2303
3375
|
emit(f);
|
|
2304
3376
|
// Agent sessions only: the hook's pseudo-session and one-shot CLI
|
|
2305
3377
|
// calls were counted as "active" sessions (field report, 2026-09-28).
|
|
2306
|
-
const
|
|
3378
|
+
const hookActivity = (st.hook_activity ?? {});
|
|
3379
|
+
const hookLastAt = hookActivity.last_at;
|
|
2307
3380
|
for (const f of sessionsFindings(sessions, {
|
|
2308
3381
|
uptimeS: Number(st.daemon_uptime_s) || 0,
|
|
2309
3382
|
prompts7d: analyzed,
|
|
3383
|
+
unoriented7d: count(hookActivity.abstained_unanchored) + count(hookActivity.abstained_hintcap),
|
|
2310
3384
|
hookLastAt: typeof hookLastAt === "number" ? hookLastAt : null,
|
|
2311
3385
|
nowS: Math.floor(Date.now() / 1000),
|
|
2312
3386
|
}))
|
|
@@ -2329,7 +3403,8 @@ async function doctorChecks(onWorkspace) {
|
|
|
2329
3403
|
}
|
|
2330
3404
|
catch { /* the root as given */ }
|
|
2331
3405
|
const neverServed = [os.homedir(), vexpHome()];
|
|
2332
|
-
|
|
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))
|
|
2333
3408
|
emit(f);
|
|
2334
3409
|
}
|
|
2335
3410
|
}
|
|
@@ -2338,6 +3413,8 @@ async function doctorChecks(onWorkspace) {
|
|
|
2338
3413
|
// built by a daemon that is no longer running.
|
|
2339
3414
|
// Every connected repo has its own coverage.json; each finding carries the
|
|
2340
3415
|
// repo alias so a run at the primary sees the whole workspace.
|
|
3416
|
+
for (const f of workspaceSkippedRepoFindings(ws.root))
|
|
3417
|
+
line(f.level, f.message);
|
|
2341
3418
|
for (const f of workspaceCoverageFindings(ws.root))
|
|
2342
3419
|
line(f.level, f.message);
|
|
2343
3420
|
// Whether each index applies the ignore rules on disk, read from the index
|
|
@@ -2379,7 +3456,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
2379
3456
|
}
|
|
2380
3457
|
}
|
|
2381
3458
|
const inReg = entries.some(([r]) => samePath(r, ws.root));
|
|
2382
|
-
|
|
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)
|
|
2383
3464
|
line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
|
|
2384
3465
|
// Multi-daemon + global pin: catches the case the section-1 check misses
|
|
2385
3466
|
// (doctor run from the same dir VEXP_WORKSPACE points at). With several
|
|
@@ -2459,11 +3540,19 @@ async function doctorChecks(onWorkspace) {
|
|
|
2459
3540
|
// Whether this project chose Codex decides every Codex remedy below: a
|
|
2460
3541
|
// user who did not is never sent to `vexp setup`, which on 3.2.5 set Codex
|
|
2461
3542
|
// up again from its own leftovers.
|
|
2462
|
-
|
|
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).
|
|
2463
3552
|
const codexConfigs = [
|
|
2464
|
-
{ label: "~/.codex/config.toml", file:
|
|
2465
|
-
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
|
|
2466
|
-
];
|
|
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));
|
|
2467
3556
|
let codexStanzaSeen = false;
|
|
2468
3557
|
// An entry Codex can start: the hook verdict below says "the tools work"
|
|
2469
3558
|
// only after one (not after a url + command one, which Codex refuses), and
|
|
@@ -2473,6 +3562,9 @@ async function doctorChecks(onWorkspace) {
|
|
|
2473
3562
|
// 2026-09-24).
|
|
2474
3563
|
let codexMcpUsable = false;
|
|
2475
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;
|
|
2476
3568
|
const codexEntryHere = (section) => {
|
|
2477
3569
|
const pin = codexSectionPin(section);
|
|
2478
3570
|
if ((pin.path === undefined && pin.hash === undefined) || codexSectionPinnedTo(section, ws.root))
|
|
@@ -2480,7 +3572,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2480
3572
|
codexMcpElsewhere ??= pin.path ?? "another project";
|
|
2481
3573
|
return false;
|
|
2482
3574
|
};
|
|
2483
|
-
for (const { label, file } of codexConfigs) {
|
|
3575
|
+
for (const { label, file, machineWide } of codexConfigs) {
|
|
2484
3576
|
if (!fs.existsSync(file)) {
|
|
2485
3577
|
line(OK, `${label}: absent`);
|
|
2486
3578
|
continue;
|
|
@@ -2505,6 +3597,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2505
3597
|
else if (hasUrl) {
|
|
2506
3598
|
if (codexEntryHere(section))
|
|
2507
3599
|
codexMcpUsable = true;
|
|
3600
|
+
codexHttp = true;
|
|
2508
3601
|
line(OK, `${label}: transport http (url)`);
|
|
2509
3602
|
// The URL routes to one workspace's daemon, so this form cannot follow
|
|
2510
3603
|
// the session's folder the way the direct one does.
|
|
@@ -2514,12 +3607,31 @@ async function doctorChecks(onWorkspace) {
|
|
|
2514
3607
|
}
|
|
2515
3608
|
else if (hasCmd) {
|
|
2516
3609
|
const here = codexEntryHere(section);
|
|
2517
|
-
|
|
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)
|
|
2518
3625
|
codexMcpUsable = true;
|
|
2519
3626
|
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
2520
3627
|
const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
|
|
2521
3628
|
const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
|
|
2522
|
-
|
|
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 });
|
|
2523
3635
|
const pin = codexSectionPin(section).path;
|
|
2524
3636
|
if (!here && pin !== undefined && section.includes("# vexp-managed"))
|
|
2525
3637
|
line(WARN, codexStdioPinNote(section, pin, ws.root));
|
|
@@ -2533,15 +3645,28 @@ async function doctorChecks(onWorkspace) {
|
|
|
2533
3645
|
// 5) Claude Code entry (should be UNPINNED after the multi-session fix).
|
|
2534
3646
|
console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
|
|
2535
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;
|
|
2536
3651
|
try {
|
|
2537
3652
|
const cfg = JSON.parse(fs.readFileSync(claudePath, "utf-8"));
|
|
2538
3653
|
const v = cfg?.mcpServers?.vexp;
|
|
2539
3654
|
if (!v)
|
|
2540
3655
|
line(OK, "no user-scope vexp entry");
|
|
2541
|
-
else
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
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
|
+
}
|
|
2545
3670
|
}
|
|
2546
3671
|
catch {
|
|
2547
3672
|
line(OK, "no ~/.claude.json");
|
|
@@ -2781,9 +3906,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2781
3906
|
})();
|
|
2782
3907
|
if (!settings) {
|
|
2783
3908
|
// Only a finding for someone who actually uses Claude Code here.
|
|
2784
|
-
|
|
2785
|
-
? "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)."
|
|
2786
|
-
: "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 }));
|
|
2787
3910
|
}
|
|
2788
3911
|
else {
|
|
2789
3912
|
// Hooks Claude Code cannot run on this PC for want of Git Bash: one line
|
|
@@ -2799,7 +3922,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2799
3922
|
line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
|
|
2800
3923
|
}
|
|
2801
3924
|
else {
|
|
2802
|
-
line(WARN, `${w.event} (${w.label}) not installed —
|
|
3925
|
+
line(WARN, `${w.event} (${w.label}) not installed — to write it: ${setupAgentsRemedy(install, ["Claude Code"], ws.root)}.`);
|
|
2803
3926
|
}
|
|
2804
3927
|
continue;
|
|
2805
3928
|
}
|
|
@@ -3135,73 +4258,18 @@ async function doctorChecks(onWorkspace) {
|
|
|
3135
4258
|
}
|
|
3136
4259
|
}
|
|
3137
4260
|
}
|
|
3138
|
-
//
|
|
3139
|
-
// keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
|
|
3140
|
-
// stop it: the old process runs the old comparison. The only fix is to end
|
|
3141
|
-
// it, so doctor has to name it — with its pid, which the record now
|
|
3142
|
-
// carries. A tester spent a night on a :7821 server that came back with a
|
|
3143
|
-
// new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
|
|
3144
|
-
// was not installed anywhere: the LABEL came from a `vexp serve` resident
|
|
3145
|
-
// since before the upgrade, holding its own version in memory
|
|
3146
|
-
// (field report, 2026-09-06).
|
|
3147
|
-
const staleSupervisorLine = (rec) => {
|
|
3148
|
-
const v = supervisorVerdict(rec, CLI_VERSION, {
|
|
3149
|
-
parentOf: parentPid,
|
|
3150
|
-
alive: isAlive,
|
|
3151
|
-
});
|
|
3152
|
-
if (v.kind === "ok")
|
|
3153
|
-
return;
|
|
3154
|
-
if (v.kind === "unknown") {
|
|
3155
|
-
line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
|
|
3156
|
-
` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
|
|
3157
|
-
return;
|
|
3158
|
-
}
|
|
3159
|
-
const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
|
|
3160
|
-
const provenance = v.by
|
|
3161
|
-
? `running v${v.by}, not v${CLI_VERSION}`
|
|
3162
|
-
: `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
|
|
3163
|
-
line(WARN, `that server was spawned by a ${who} ${provenance}` +
|
|
3164
|
-
(v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
|
|
3165
|
-
`\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
|
|
3166
|
-
// Ending it is the whole remediation: login autostart (or the next
|
|
3167
|
-
// `vexp` command) starts a fresh supervisor, which reads the current
|
|
3168
|
-
// package.json and serves the current build. The tester who did this
|
|
3169
|
-
// had a correct server one second later without running anything
|
|
3170
|
-
// else — and the `vexp setup` this line used to recommend blocks on
|
|
3171
|
-
// an interactive prompt, which for the reader of a doctor line is a
|
|
3172
|
-
// hang, not a fix (field report, 2026-09-07).
|
|
3173
|
-
(v.alive
|
|
3174
|
-
? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
|
|
3175
|
-
// The order is load-bearing and nothing said so: an old
|
|
3176
|
-
// supervisor's health loop rebuilds the daemon registry every 60s
|
|
3177
|
-
// from its own rows, so it deletes each daemon's registration as
|
|
3178
|
-
// fast as the daemon writes it. Restart the daemons first and they
|
|
3179
|
-
// come back invisible — and nobody restarts a daemon they have
|
|
3180
|
-
// just restarted (field report, 2026-09-08).
|
|
3181
|
-
`\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
|
|
3182
|
-
`\n restarted before it is gone comes back invisible to 'vexp daemons'.`
|
|
3183
|
-
: ""));
|
|
3184
|
-
};
|
|
3185
|
-
// 6) HTTP MCP supervisor.
|
|
4261
|
+
// 6) HTTP MCP supervisor. Every decision is in httpServerFindings.
|
|
3186
4262
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
3187
4263
|
try {
|
|
3188
4264
|
const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
// saw two vexp servers with different tool counts and could not tell why.
|
|
3198
|
-
if (alive && rec.version && rec.version !== CLI_VERSION) {
|
|
3199
|
-
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`);
|
|
3200
|
-
staleSupervisorLine(rec);
|
|
3201
|
-
}
|
|
3202
|
-
else if (alive && !rec.version) {
|
|
3203
|
-
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}`);
|
|
3204
|
-
}
|
|
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);
|
|
3205
4273
|
}
|
|
3206
4274
|
catch {
|
|
3207
4275
|
line(OK, "not running (no mcp.pid)");
|
|
@@ -3215,7 +4283,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3215
4283
|
if (warns > 0 || fails > 0) {
|
|
3216
4284
|
// The Codex half only where Codex was chosen: to anyone else it read as
|
|
3217
4285
|
// one more reason to set Codex up (field report, 2026-09-24).
|
|
3218
|
-
console.log(chalk.dim(`
|
|
4286
|
+
console.log(chalk.dim(` ${summaryTips(install, codexUse.used)}`));
|
|
3219
4287
|
}
|
|
3220
4288
|
console.log("");
|
|
3221
4289
|
// Scripts and CI read the exit code, not the colors: any FAIL exits 1.
|