vexp-cli 3.3.3 → 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 +1157 -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);
|
|
@@ -1156,6 +1907,13 @@ export function workspaceRulesFindings(root, status) {
|
|
|
1156
1907
|
* the same ledger: orientations the daemon finished after the hook client
|
|
1157
1908
|
* (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
|
|
1158
1909
|
* never read by the agent (field ledger, 3.1.3: up to 8 of 33).
|
|
1910
|
+
*
|
|
1911
|
+
* The WARN says what the count is and stops there. It used to end "long
|
|
1912
|
+
* prompts are slow on this index", a cause the ledger does not hold: a
|
|
1913
|
+
* 14-word prompt goes late behind a held index lock, and a late row is also
|
|
1914
|
+
* written for a long prompt the daemon skipped after two late ones (review
|
|
1915
|
+
* of a support ticket, 2026-10-08: 10 late of 27, cause not established).
|
|
1916
|
+
* The timings that separate the causes are in the report, so it points there.
|
|
1159
1917
|
*/
|
|
1160
1918
|
export function ledgerFindings(ledger) {
|
|
1161
1919
|
const out = [];
|
|
@@ -1172,9 +1930,11 @@ export function ledgerFindings(ledger) {
|
|
|
1172
1930
|
});
|
|
1173
1931
|
}
|
|
1174
1932
|
if (late > 0) {
|
|
1933
|
+
const served = Number(ledger.hints_served) || 0;
|
|
1175
1934
|
out.push({
|
|
1176
1935
|
level: WARN,
|
|
1177
|
-
message: `${late} orientation(s) in the last 7 days
|
|
1936
|
+
message: `${late} orientation(s) in the last 7 days were recorded late, against ${served} served: not ready within the hook client's wait (3 s by default), or a long prompt skipped after two late ones in its session. A late one normally does not reach the agent.\n` +
|
|
1937
|
+
` what made them late is not recorded here: 'doctor --report' writes the timings (the slowest calls in its usage ledger, the slow statements in its daemon log section)`,
|
|
1178
1938
|
});
|
|
1179
1939
|
}
|
|
1180
1940
|
return out;
|
|
@@ -1264,6 +2024,16 @@ export function sessionsFindings(sessions, o) {
|
|
|
1264
2024
|
else if (o.prompts7d > 0) {
|
|
1265
2025
|
out.push({ level: OK, message: `no agent tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)` });
|
|
1266
2026
|
}
|
|
2027
|
+
else if ((o.unoriented7d ?? 0) > 0) {
|
|
2028
|
+
// Since the seed gate a prompt the search found nothing solid for gets
|
|
2029
|
+
// nothing and is in no ledger number: twelve such prompts in a week
|
|
2030
|
+
// read here as the WARN below ("check its MCP config"), directly under
|
|
2031
|
+
// the line saying the hook had reached vexp twelve times.
|
|
2032
|
+
out.push({
|
|
2033
|
+
level: OK,
|
|
2034
|
+
message: `no agent tool calls in the last 4h — but the prompt hook is reaching vexp: ${o.unoriented7d} prompt(s) in the last 7 days were searched and not oriented (the hook line above says why); zero tool calls is normal`,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
1267
2037
|
else if (o.uptimeS > 600) {
|
|
1268
2038
|
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
1269
2039
|
// the daemon was unreachable serves from its own in-process index and
|
|
@@ -1300,6 +2070,15 @@ export function sessionsFindings(sessions, o) {
|
|
|
1300
2070
|
* `hook_activity` (7 days) as doctor lines, apart from the savings ledger,
|
|
1301
2071
|
* whose numbers these abstentions never change. Nothing for a daemon that
|
|
1302
2072
|
* predates the key.
|
|
2073
|
+
*
|
|
2074
|
+
* Since the seed gate two more kinds of prompt get nothing: a typed prompt
|
|
2075
|
+
* the search found nothing for that passed the gate (it used to get the
|
|
2076
|
+
* one-line notice, or a seed on thin evidence, and "prompts analyzed" then
|
|
2077
|
+
* counted it), and a prompt whose one-line notice the session had already
|
|
2078
|
+
* received. Without them here a user who upgrades sees the ledger's count
|
|
2079
|
+
* fall and no line that says where the prompts went. The gate's third kind
|
|
2080
|
+
* is no abstention: a definition list served alone, with the seed switched
|
|
2081
|
+
* off, is in "hints served", and the aside says how many of those it was.
|
|
1303
2082
|
*/
|
|
1304
2083
|
export function hookActivityFindings(ha, ledger, nowS) {
|
|
1305
2084
|
if (!ha || typeof ha !== "object" || Array.isArray(ha))
|
|
@@ -1310,21 +2089,45 @@ export function hookActivityFindings(ha, ledger, nowS) {
|
|
|
1310
2089
|
const empty = count(h.abstained_empty_index);
|
|
1311
2090
|
// Absent from a daemon that predates the key: zero.
|
|
1312
2091
|
const agentText = count(h.abstained_agent_text);
|
|
1313
|
-
const
|
|
2092
|
+
const unanchored = count(h.abstained_unanchored);
|
|
2093
|
+
const noticeHad = count(h.abstained_hintcap);
|
|
2094
|
+
// The first three were never searched: skipped. The gate's two were
|
|
2095
|
+
// classified and searched, and then nothing was sent: "skipped" is not
|
|
2096
|
+
// what happened to them, so a line that holds any says "not oriented".
|
|
2097
|
+
const unoriented = unanchored + noticeHad;
|
|
2098
|
+
const skipped = short + empty + agentText + unoriented;
|
|
1314
2099
|
const analyzed = count(ledger.prompts_analyzed);
|
|
1315
2100
|
const parts = [
|
|
1316
2101
|
short > 0 ? `${short} under 8 words (follow-ups are never oriented)` : "",
|
|
1317
2102
|
empty > 0 ? `${empty} while the index was empty` : "",
|
|
1318
2103
|
agentText > 0 ? `${agentText} that were the agent's own text, nothing typed (a task notice, an approval)` : "",
|
|
2104
|
+
unanchored > 0 ? `${unanchored} typed and searched with nothing found that passed the seed gate (no orientation spent)` : "",
|
|
2105
|
+
noticeHad > 0 ? `${noticeHad} that would have repeated the one-line notice (sent once per session)` : "",
|
|
1319
2106
|
]
|
|
1320
2107
|
.filter(Boolean)
|
|
1321
2108
|
.join(", ");
|
|
1322
2109
|
const out = [];
|
|
1323
2110
|
if (skipped > 0 && analyzed === 0) {
|
|
1324
|
-
|
|
2111
|
+
const what = unoriented > 0 ? "no prompt was oriented" : "every prompt was skipped";
|
|
2112
|
+
out.push({ level: OK, message: `orientation hook reached vexp ${skipped} time(s) in the last ${days} days and ${what}: ${parts}` });
|
|
1325
2113
|
}
|
|
1326
2114
|
else if (skipped > 0) {
|
|
1327
|
-
|
|
2115
|
+
const what = unoriented === 0
|
|
2116
|
+
? `skipped ${skipped} prompt(s)`
|
|
2117
|
+
: unoriented === skipped
|
|
2118
|
+
? `left ${skipped} prompt(s) unoriented`
|
|
2119
|
+
: `skipped or left unoriented ${skipped} prompt(s)`;
|
|
2120
|
+
out.push({ level: NOTE, message: `the hook also ${what} in the last ${days} days: ${parts}` });
|
|
2121
|
+
}
|
|
2122
|
+
// Past tense: the count is of the window, not of today's config. "The
|
|
2123
|
+
// seed is switched off" was printed for seven days after the key had
|
|
2124
|
+
// been set for one afternoon and removed.
|
|
2125
|
+
const definitionLists = count(h.evidence_alone);
|
|
2126
|
+
if (definitionLists > 0) {
|
|
2127
|
+
out.push({
|
|
2128
|
+
level: NOTE,
|
|
2129
|
+
message: `${definitionLists} of the hints served in the last ${days} days were a definition list alone, served while the seed was switched off (ambient_seed = false or VEXP_AMBIENT_SEED=0)`,
|
|
2130
|
+
});
|
|
1328
2131
|
}
|
|
1329
2132
|
const last = typeof h.last_at === "number" && Number.isFinite(h.last_at) ? h.last_at : null;
|
|
1330
2133
|
if (last !== null) {
|
|
@@ -1391,7 +2194,7 @@ export function pathRelation(a, b, platform) {
|
|
|
1391
2194
|
return "unrelated";
|
|
1392
2195
|
}
|
|
1393
2196
|
const HOOK_OUTCOME_TROUBLE = {
|
|
1394
|
-
"no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation
|
|
2197
|
+
"no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation",
|
|
1395
2198
|
timeout: "the daemon did not answer within the hook client's budget (3 s by default), so that prompt got no orientation",
|
|
1396
2199
|
error: "the daemon answered with an error, so that prompt got no orientation; .vexp/daemon.log has the details",
|
|
1397
2200
|
};
|
|
@@ -1405,8 +2208,10 @@ const HOOK_OUTCOME_TROUBLE = {
|
|
|
1405
2208
|
* `neverServed` are folders no daemon can ever serve (the home directory,
|
|
1406
2209
|
* the vexp home): a user-level hook (ZCode's, Codex's) meets no daemon there
|
|
1407
2210
|
* every time a session opens in them, which says nothing about this project.
|
|
2211
|
+
* `startsOne` is what starts a daemon for the reader's install: the command,
|
|
2212
|
+
* unless they have the extension and no `vexp` to run it with.
|
|
1408
2213
|
*/
|
|
1409
|
-
export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = []) {
|
|
2214
|
+
export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = [], startsOne = "'vexp daemon-cmd start' starts one") {
|
|
1410
2215
|
const roots = [wsRoot, ...aliases.filter((a) => !!a)];
|
|
1411
2216
|
const relation = (e) => {
|
|
1412
2217
|
const rels = roots.map((r) => pathRelation(e.root, r, platform));
|
|
@@ -1416,7 +2221,8 @@ export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [],
|
|
|
1416
2221
|
const out = [];
|
|
1417
2222
|
const here = entries.filter((e) => relation(e) === "same").sort((a, b) => b.at - a.at)[0];
|
|
1418
2223
|
if (here) {
|
|
1419
|
-
const
|
|
2224
|
+
const what = HOOK_OUTCOME_TROUBLE[here.outcome];
|
|
2225
|
+
const trouble = what && here.outcome === "no-daemon" ? `${what} (${startsOne})` : what;
|
|
1420
2226
|
const reason = here.reason ? `: ${here.reason.slice(0, 200)}` : "";
|
|
1421
2227
|
out.push({
|
|
1422
2228
|
level: trouble ? WARN : OK,
|
|
@@ -2040,15 +2846,155 @@ export function buildDoctorReport(i) {
|
|
|
2040
2846
|
parts.push("");
|
|
2041
2847
|
return maskSecretsForReport(parts.join("\n"));
|
|
2042
2848
|
}
|
|
2849
|
+
// The timestamp and level a daemon log line starts with, as the engine's
|
|
2850
|
+
// human format writes them (`2026-10-08T10:16:38.645986Z WARN message`).
|
|
2851
|
+
const LOG_STAMP = String.raw `^\s*(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+`;
|
|
2852
|
+
const LOG_LINE = new RegExp(`${LOG_STAMP}(TRACE|DEBUG|INFO|WARN|ERROR)\\b`);
|
|
2853
|
+
// The records that carry a prompt: the engine logs the query of every
|
|
2854
|
+
// capsule at INFO, as the user typed it, newlines included (mcp/capsule.rs,
|
|
2855
|
+
// the same four lines from 2.7.0 to 3.3.3), and a daemon started at the
|
|
2856
|
+
// debug level logs it in three more (mcp/pipeline.rs, memory/search.rs).
|
|
2857
|
+
// Each closes with a tail of its own, but for one: "No pivot nodes found"
|
|
2858
|
+
// ends where the query ends, and always comes after "Generating capsule"
|
|
2859
|
+
// for the same query. `loose` is a tail too plain to be looked for outside
|
|
2860
|
+
// its record (the closing quote of 80 bytes of the task).
|
|
2861
|
+
const promptRecord = (level, opens) => new RegExp(`${LOG_STAMP}${level}\\s+${opens}`);
|
|
2862
|
+
const PROMPT_RECORDS = [
|
|
2863
|
+
{ start: promptRecord("INFO", "Generating capsule for query: "), end: / \[intent=[^\]]*, budget=\d+\]\s*$/ },
|
|
2864
|
+
{ start: promptRecord("INFO", "Pivot threshold yielded 0 hits for query '"), end: /'; promoting top \d+ code BM25 candidate\(s\)\s*$/ },
|
|
2865
|
+
{ start: promptRecord("INFO", "Only doc/data nodes matched code-action query '"), end: /'; returning honest empty capsule\s*$/ },
|
|
2866
|
+
{ start: promptRecord("INFO", "No pivot nodes found for query: ") },
|
|
2867
|
+
{ start: promptRecord("DEBUG", "T5 query expansion: '"), end: /' -> \[.*\]\s*$/ },
|
|
2868
|
+
{ start: promptRecord("DEBUG", "Memory search for '"), end: /': \d+ candidates, \d+ results\s*$/ },
|
|
2869
|
+
{ start: promptRecord("DEBUG", "B\\.15 preprocessor: running for task '"), end: /'\s*$/, loose: true },
|
|
2870
|
+
];
|
|
2871
|
+
const PROMPT_RECORD_ENDS = PROMPT_RECORDS.flatMap((r) => (r.end && !r.loose ? [r.end] : []));
|
|
2872
|
+
// A Rust panic's first line: the thread and where in the source, no text.
|
|
2873
|
+
// The toolchain the engine is built with prints the thread's id after its
|
|
2874
|
+
// name (`thread 'main' (1692879) panicked at`); older ones did not.
|
|
2875
|
+
const PANIC_LINE = /^thread '[^']*'(?: \(\d+\))? panicked at /;
|
|
2876
|
+
// SQLite quotes back the piece of a full-text query it could not read, and
|
|
2877
|
+
// for a capsule's search that piece is a word of the prompt.
|
|
2878
|
+
const SQLITE_ECHO = /(error returned from database: \(code: \d+\) (?:unknown special query: |fts5: syntax error near ))(.+)$/;
|
|
2879
|
+
// The engine now leaves that piece out of its log itself, and writes how
|
|
2880
|
+
// long it was in its place (log_text.rs: "(11 chars of the query, not
|
|
2881
|
+
// logged)"). A tail that reads so is the engine's note, not a word of the
|
|
2882
|
+
// prompt: wrapped again it came out as "(35 chars of the query, not
|
|
2883
|
+
// quoted)", the length of the note.
|
|
2884
|
+
const SQLITE_ECHO_LEFT_OUT = /^\(\d+ chars of the query, not logged\)/;
|
|
2885
|
+
const LOG_VALUE_MAX = 120;
|
|
2886
|
+
const LOG_LINE_MAX = 600;
|
|
2887
|
+
/** A WARN or ERROR line cut to what a reader needs: a long quoted value (the
|
|
2888
|
+
* SQL of a slow statement runs to 700 characters) is shortened where it
|
|
2889
|
+
* stands, so the fields after it, the timings, are kept. */
|
|
2890
|
+
function shortenLogLine(line) {
|
|
2891
|
+
const cut = line
|
|
2892
|
+
.replace(SQLITE_ECHO, (whole, head, echoed) => SQLITE_ECHO_LEFT_OUT.test(echoed) ? whole : `${head}(${echoed.length} chars of the query, not quoted)`)
|
|
2893
|
+
.replace(/="((?:[^"\\]|\\.)*)"/g, (whole, value) => value.length > LOG_VALUE_MAX ? `="${value.slice(0, LOG_VALUE_MAX)}... (+${value.length - LOG_VALUE_MAX} chars)"` : whole);
|
|
2894
|
+
return cut.length > LOG_LINE_MAX ? `${cut.slice(0, LOG_LINE_MAX)}... (+${cut.length - LOG_LINE_MAX} chars)` : cut;
|
|
2895
|
+
}
|
|
2896
|
+
/** The level of a line of the engine's JSON log format (VEXP_LOG_FORMAT=json:
|
|
2897
|
+
* one object per record, with `timestamp` and an upper-case `level`), or
|
|
2898
|
+
* undefined for any other line, a JSON line of someone else's log included. */
|
|
2899
|
+
function jsonLogLevel(line) {
|
|
2900
|
+
if (!line.startsWith("{"))
|
|
2901
|
+
return undefined;
|
|
2902
|
+
try {
|
|
2903
|
+
const rec = JSON.parse(line);
|
|
2904
|
+
return typeof rec?.level === "string" && typeof rec.timestamp === "string" ? rec.level : undefined;
|
|
2905
|
+
}
|
|
2906
|
+
catch {
|
|
2907
|
+
return undefined;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2043
2910
|
/**
|
|
2044
2911
|
* Warnings and errors from the daemon log tail, ANSI stripped, newest last.
|
|
2045
2912
|
* Repeats of one message (the same config warning on every start) collapse
|
|
2046
2913
|
* into the latest occurrence with a count: a report is read by a person,
|
|
2047
2914
|
* and eighty identical lines hide the one that differs.
|
|
2915
|
+
*
|
|
2916
|
+
* Only lines the daemon itself logged at WARN or ERROR, and the first line
|
|
2917
|
+
* of a panic. The filter used to keep any line that CONTAINED one of those
|
|
2918
|
+
* words, and the daemon log holds every oriented prompt in full: a prompt
|
|
2919
|
+
* with a pasted error log put its lines in a report whose header says it
|
|
2920
|
+
* never carries prompt text (review of a support ticket, 2026-10-08: 10 of
|
|
2921
|
+
* the 11 lines quoted from a test log were lines of a prompt). A pasted log
|
|
2922
|
+
* can itself be in the engine's format, so the lines of a prompt record are
|
|
2923
|
+
* skipped whatever they look like.
|
|
2924
|
+
*
|
|
2925
|
+
* Where a prompt ends cannot always be read off the log, and wherever it
|
|
2926
|
+
* cannot the line is left out: a report that misses a warning is a smaller
|
|
2927
|
+
* fault than one that quotes a prompt. So the log is read from its top (a
|
|
2928
|
+
* record that began above the lines quoted is still a record), a record
|
|
2929
|
+
* runs to its closing tail however long it is, and three more rules cover
|
|
2930
|
+
* what a first version let through (review of the fix, 2026-10-08):
|
|
2931
|
+
* - a line seen inside a prompt is never quoted afterwards, which is what
|
|
2932
|
+
* covers the record with no tail;
|
|
2933
|
+
* - a closing tail with no record open means the record was closed early
|
|
2934
|
+
* by a tail pasted inside the prompt (a piece of vexp's own log), and
|
|
2935
|
+
* everything quoted since that record began is taken back;
|
|
2936
|
+
* - a record that never closes (a line cut short, or an engine that words
|
|
2937
|
+
* it differently) stops at the next record logged after it, and when
|
|
2938
|
+
* there is none the count of lines left out is said.
|
|
2048
2939
|
*/
|
|
2049
2940
|
export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
2050
|
-
const lines = stripAnsi(text).trimEnd().split("\n").
|
|
2051
|
-
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
|
+
}
|
|
2052
2998
|
const byMessage = new Map();
|
|
2053
2999
|
for (const l of hits) {
|
|
2054
3000
|
const key = l.replace(/^\S*\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*/, "");
|
|
@@ -2062,9 +3008,27 @@ export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
|
2062
3008
|
else
|
|
2063
3009
|
byMessage.set(key, { last: l, count: 1 });
|
|
2064
3010
|
}
|
|
2065
|
-
|
|
3011
|
+
const out = [...byMessage.values()]
|
|
2066
3012
|
.map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
|
|
2067
3013
|
.slice(-keep);
|
|
3014
|
+
const unread = open ? lines.length - 1 - Math.max(open.line, from - 1) : 0;
|
|
3015
|
+
if (unread > 0)
|
|
3016
|
+
out.push(`(${unread} line(s) at the end of the log were not read: they follow a logged prompt whose end was not found)`);
|
|
3017
|
+
return out;
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* The daemon log the report quotes: the one written last. `daemon.log` is
|
|
3021
|
+
* the CLI-started daemon's and `vexp.log` the IDE extension's; with both in
|
|
3022
|
+
* .vexp the report took `daemon.log` whatever its age, so an extension
|
|
3023
|
+
* user who had once started a daemon from a terminal sent the warnings of
|
|
3024
|
+
* that old run under the heading of today's (review, 2026-10-08).
|
|
3025
|
+
*/
|
|
3026
|
+
export function newestLogName(mtimes) {
|
|
3027
|
+
let best = null;
|
|
3028
|
+
for (const m of mtimes)
|
|
3029
|
+
if (!best || m.mtimeMs > best.mtimeMs)
|
|
3030
|
+
best = m;
|
|
3031
|
+
return best ? best.name : null;
|
|
2068
3032
|
}
|
|
2069
3033
|
/** The home folders iCloud's "Desktop & Documents Folders" syncs on this
|
|
2070
3034
|
* Mac: the ones com~apple~CloudDocs holds a symlink to. Sync off, no link. */
|
|
@@ -2131,15 +3095,23 @@ export async function writeDoctorReport(o) {
|
|
|
2131
3095
|
ledgerMarkdown = String(ledger.stdout);
|
|
2132
3096
|
let logName = null;
|
|
2133
3097
|
let logWarnings = [];
|
|
3098
|
+
const logs = [];
|
|
2134
3099
|
for (const name of ["daemon.log", "vexp.log"]) {
|
|
2135
3100
|
try {
|
|
2136
|
-
|
|
2137
|
-
logName = name;
|
|
2138
|
-
logWarnings = logWarningsFrom(text);
|
|
2139
|
-
break;
|
|
3101
|
+
logs.push({ name, mtimeMs: fs.statSync(path.join(o.root, ".vexp", name)).mtimeMs });
|
|
2140
3102
|
}
|
|
2141
3103
|
catch {
|
|
2142
|
-
/*
|
|
3104
|
+
/* no such log */
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
const newest = newestLogName(logs);
|
|
3108
|
+
if (newest) {
|
|
3109
|
+
try {
|
|
3110
|
+
logWarnings = logWarningsFrom(fs.readFileSync(path.join(o.root, ".vexp", newest), "utf-8"));
|
|
3111
|
+
logName = newest;
|
|
3112
|
+
}
|
|
3113
|
+
catch {
|
|
3114
|
+
/* unreadable: the report says no log was found */
|
|
2143
3115
|
}
|
|
2144
3116
|
}
|
|
2145
3117
|
let configText = null;
|
|
@@ -2236,6 +3208,16 @@ async function doctorChecks(onWorkspace) {
|
|
|
2236
3208
|
line(f.level, f.message);
|
|
2237
3209
|
};
|
|
2238
3210
|
console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
|
|
3211
|
+
// Which install the reader has decides every remedy below (see
|
|
3212
|
+
// InstallContext): read once.
|
|
3213
|
+
const install = installContextHere();
|
|
3214
|
+
{
|
|
3215
|
+
const origin = npxOriginNote(CLI_VERSION, install.npxCache);
|
|
3216
|
+
if (origin) {
|
|
3217
|
+
line(INFO, origin);
|
|
3218
|
+
console.log("");
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
2239
3221
|
// 1) Workspace resolution for the current directory.
|
|
2240
3222
|
console.log(chalk.bold("Workspace targeting"));
|
|
2241
3223
|
const resolved = resolveWorkspace();
|
|
@@ -2264,9 +3246,29 @@ async function doctorChecks(onWorkspace) {
|
|
|
2264
3246
|
}
|
|
2265
3247
|
const sock = socketForWorkspace(ws.root);
|
|
2266
3248
|
const live = await reachable(sock);
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
3249
|
+
// No daemon, the extension and no `vexp` command: most often a project
|
|
3250
|
+
// that is simply not open in the IDE, which is no fault at all.
|
|
3251
|
+
let closedInIde = false;
|
|
3252
|
+
if (live)
|
|
3253
|
+
line(OK, `daemon socket reachable: ${sock}`);
|
|
3254
|
+
else {
|
|
3255
|
+
let startBlocked;
|
|
3256
|
+
try {
|
|
3257
|
+
startBlocked = fs.readFileSync(path.join(ws.root, ".vexp", "start-blocked"), "utf-8");
|
|
3258
|
+
}
|
|
3259
|
+
catch { /* the last start was not refused */ }
|
|
3260
|
+
const what = notAProject(ws.root);
|
|
3261
|
+
const down = daemonDownFindings({
|
|
3262
|
+
sock,
|
|
3263
|
+
install,
|
|
3264
|
+
hasIndex: fs.existsSync(path.join(ws.root, ".vexp", "index.db")),
|
|
3265
|
+
startBlocked,
|
|
3266
|
+
outside: what ? { root: ws.root, what } : undefined,
|
|
3267
|
+
});
|
|
3268
|
+
closedInIde = down.every((f) => f.level === INFO);
|
|
3269
|
+
for (const f of down)
|
|
3270
|
+
emit(f);
|
|
3271
|
+
}
|
|
2270
3272
|
// The /tmp relocation is silent by construction — every client computes
|
|
2271
3273
|
// the same fallback and none of them says so. A tester measured his
|
|
2272
3274
|
// Claude Code worktrees at 89 of the 100 characters and pointed out that
|
|
@@ -2332,15 +3334,28 @@ async function doctorChecks(onWorkspace) {
|
|
|
2332
3334
|
const { execFileSync } = await import("node:child_process");
|
|
2333
3335
|
const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
|
|
2334
3336
|
const bundled = out.trim().split(/\s+/).pop();
|
|
2335
|
-
const
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
3337
|
+
const exe = typeof st.daemon_exe === "string" ? st.daemon_exe : undefined;
|
|
3338
|
+
// What else is installed beside the extension folder the daemon
|
|
3339
|
+
// runs from: a newer one there means the window was not reloaded.
|
|
3340
|
+
let siblings = [];
|
|
3341
|
+
const folder = extensionFolderOf(exe);
|
|
3342
|
+
if (folder) {
|
|
3343
|
+
try {
|
|
3344
|
+
siblings = fs.readdirSync(folder.extensionsDir);
|
|
3345
|
+
}
|
|
3346
|
+
catch { /* not listable from here (another user's home, a remote) */ }
|
|
2343
3347
|
}
|
|
3348
|
+
const stale = daemonVersionFinding({
|
|
3349
|
+
running: typeof st.daemon_version === "string" ? st.daemon_version : undefined,
|
|
3350
|
+
engine: bundled,
|
|
3351
|
+
exe,
|
|
3352
|
+
binaryStale: st.binary_stale === true,
|
|
3353
|
+
cliVersion: CLI_VERSION,
|
|
3354
|
+
npxCache: install.npxCache,
|
|
3355
|
+
siblings,
|
|
3356
|
+
});
|
|
3357
|
+
if (stale)
|
|
3358
|
+
emit(stale);
|
|
2344
3359
|
}
|
|
2345
3360
|
catch { /* best-effort */ }
|
|
2346
3361
|
// 2.3 C1 — which agent sessions actually used vexp (a session with zero
|
|
@@ -2360,10 +3375,12 @@ async function doctorChecks(onWorkspace) {
|
|
|
2360
3375
|
emit(f);
|
|
2361
3376
|
// Agent sessions only: the hook's pseudo-session and one-shot CLI
|
|
2362
3377
|
// calls were counted as "active" sessions (field report, 2026-09-28).
|
|
2363
|
-
const
|
|
3378
|
+
const hookActivity = (st.hook_activity ?? {});
|
|
3379
|
+
const hookLastAt = hookActivity.last_at;
|
|
2364
3380
|
for (const f of sessionsFindings(sessions, {
|
|
2365
3381
|
uptimeS: Number(st.daemon_uptime_s) || 0,
|
|
2366
3382
|
prompts7d: analyzed,
|
|
3383
|
+
unoriented7d: count(hookActivity.abstained_unanchored) + count(hookActivity.abstained_hintcap),
|
|
2367
3384
|
hookLastAt: typeof hookLastAt === "number" ? hookLastAt : null,
|
|
2368
3385
|
nowS: Math.floor(Date.now() / 1000),
|
|
2369
3386
|
}))
|
|
@@ -2386,7 +3403,8 @@ async function doctorChecks(onWorkspace) {
|
|
|
2386
3403
|
}
|
|
2387
3404
|
catch { /* the root as given */ }
|
|
2388
3405
|
const neverServed = [os.homedir(), vexpHome()];
|
|
2389
|
-
|
|
3406
|
+
const startsOne = extensionOnly(install) ? `the vexp extension starts one when the project's window opens in ${ideName(install.extensions)}` : undefined;
|
|
3407
|
+
for (const f of hookCallFindings(parseHookCalls(text), ws.root, Math.floor(Date.now() / 1000), process.platform, aliases, neverServed, startsOne))
|
|
2390
3408
|
emit(f);
|
|
2391
3409
|
}
|
|
2392
3410
|
}
|
|
@@ -2438,7 +3456,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
2438
3456
|
}
|
|
2439
3457
|
}
|
|
2440
3458
|
const inReg = entries.some(([r]) => samePath(r, ws.root));
|
|
2441
|
-
|
|
3459
|
+
// A project with no daemon has no row: said once, above, when that is
|
|
3460
|
+
// its ordinary state.
|
|
3461
|
+
if (!inReg && closedInIde)
|
|
3462
|
+
line(INFO, `current workspace (${ws.root}) is not registered: no daemon is running for it (see 'Workspace targeting' above)`);
|
|
3463
|
+
else if (!inReg)
|
|
2442
3464
|
line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
|
|
2443
3465
|
// Multi-daemon + global pin: catches the case the section-1 check misses
|
|
2444
3466
|
// (doctor run from the same dir VEXP_WORKSPACE points at). With several
|
|
@@ -2518,11 +3540,19 @@ async function doctorChecks(onWorkspace) {
|
|
|
2518
3540
|
// Whether this project chose Codex decides every Codex remedy below: a
|
|
2519
3541
|
// user who did not is never sent to `vexp setup`, which on 3.2.5 set Codex
|
|
2520
3542
|
// up again from its own leftovers.
|
|
2521
|
-
|
|
3543
|
+
// The machine-wide file is never the project's own, also when doctor is
|
|
3544
|
+
// run from the home folder and the two paths are one: read as the
|
|
3545
|
+
// project's, it made the home "a project set up for Codex".
|
|
3546
|
+
const codexGlobalConfig = path.join(os.homedir(), ".codex", "config.toml");
|
|
3547
|
+
const codexUse = codexUseHere(ws.root, undefined, (p) => fs.existsSync(p) && !samePath(p, codexGlobalConfig));
|
|
3548
|
+
// The machine-wide file, then the project's own. From the home folder the
|
|
3549
|
+
// two are one file: read as the project's it was printed twice, the
|
|
3550
|
+
// second time as a file "vexp does not write" (review of the fix,
|
|
3551
|
+
// 2026-10-08).
|
|
2522
3552
|
const codexConfigs = [
|
|
2523
|
-
{ label: "~/.codex/config.toml", file:
|
|
2524
|
-
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
|
|
2525
|
-
];
|
|
3553
|
+
{ label: "~/.codex/config.toml", file: codexGlobalConfig, machineWide: true },
|
|
3554
|
+
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml"), machineWide: false },
|
|
3555
|
+
].filter((c, i, all) => i === 0 || !samePath(c.file, all[0].file));
|
|
2526
3556
|
let codexStanzaSeen = false;
|
|
2527
3557
|
// An entry Codex can start: the hook verdict below says "the tools work"
|
|
2528
3558
|
// only after one (not after a url + command one, which Codex refuses), and
|
|
@@ -2532,6 +3562,9 @@ async function doctorChecks(onWorkspace) {
|
|
|
2532
3562
|
// 2026-09-24).
|
|
2533
3563
|
let codexMcpUsable = false;
|
|
2534
3564
|
let codexMcpElsewhere;
|
|
3565
|
+
// An entry on the http transport: the one client of the HTTP MCP server
|
|
3566
|
+
// that vexp itself sets up (section 6 says who talks to that server).
|
|
3567
|
+
let codexHttp = false;
|
|
2535
3568
|
const codexEntryHere = (section) => {
|
|
2536
3569
|
const pin = codexSectionPin(section);
|
|
2537
3570
|
if ((pin.path === undefined && pin.hash === undefined) || codexSectionPinnedTo(section, ws.root))
|
|
@@ -2539,7 +3572,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2539
3572
|
codexMcpElsewhere ??= pin.path ?? "another project";
|
|
2540
3573
|
return false;
|
|
2541
3574
|
};
|
|
2542
|
-
for (const { label, file } of codexConfigs) {
|
|
3575
|
+
for (const { label, file, machineWide } of codexConfigs) {
|
|
2543
3576
|
if (!fs.existsSync(file)) {
|
|
2544
3577
|
line(OK, `${label}: absent`);
|
|
2545
3578
|
continue;
|
|
@@ -2564,6 +3597,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2564
3597
|
else if (hasUrl) {
|
|
2565
3598
|
if (codexEntryHere(section))
|
|
2566
3599
|
codexMcpUsable = true;
|
|
3600
|
+
codexHttp = true;
|
|
2567
3601
|
line(OK, `${label}: transport http (url)`);
|
|
2568
3602
|
// The URL routes to one workspace's daemon, so this form cannot follow
|
|
2569
3603
|
// the session's folder the way the direct one does.
|
|
@@ -2573,12 +3607,31 @@ async function doctorChecks(onWorkspace) {
|
|
|
2573
3607
|
}
|
|
2574
3608
|
else if (hasCmd) {
|
|
2575
3609
|
const here = codexEntryHere(section);
|
|
2576
|
-
|
|
3610
|
+
// The entry is only as good as the program it names: one left pointing
|
|
3611
|
+
// into an extension folder an update removed starts nothing.
|
|
3612
|
+
const started = codexSectionCommand(section);
|
|
3613
|
+
const entry = machineEntryCheck({
|
|
3614
|
+
agent: "Codex",
|
|
3615
|
+
command: started.command,
|
|
3616
|
+
args: started.args,
|
|
3617
|
+
install,
|
|
3618
|
+
root: ws.root,
|
|
3619
|
+
machineWide,
|
|
3620
|
+
// A section without the marker is the user's: setup and the
|
|
3621
|
+
// extension both leave it as it is (isUserManagedCodexSection).
|
|
3622
|
+
managed: section.includes("# vexp-managed"),
|
|
3623
|
+
});
|
|
3624
|
+
if (here && !entry.fail)
|
|
2577
3625
|
codexMcpUsable = true;
|
|
2578
3626
|
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
2579
3627
|
const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
|
|
2580
3628
|
const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
|
|
2581
|
-
|
|
3629
|
+
if (entry.fail)
|
|
3630
|
+
line(BAD, `${label}: ${entry.fail}`);
|
|
3631
|
+
else
|
|
3632
|
+
line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin: the server starts in each Codex session's folder"}`);
|
|
3633
|
+
if (entry.note)
|
|
3634
|
+
emit({ level: NOTE, message: entry.note });
|
|
2582
3635
|
const pin = codexSectionPin(section).path;
|
|
2583
3636
|
if (!here && pin !== undefined && section.includes("# vexp-managed"))
|
|
2584
3637
|
line(WARN, codexStdioPinNote(section, pin, ws.root));
|
|
@@ -2592,15 +3645,28 @@ async function doctorChecks(onWorkspace) {
|
|
|
2592
3645
|
// 5) Claude Code entry (should be UNPINNED after the multi-session fix).
|
|
2593
3646
|
console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
|
|
2594
3647
|
const claudePath = path.join(os.homedir(), ".claude.json");
|
|
3648
|
+
// Whether the entry can start what it names: the hooks section below
|
|
3649
|
+
// speaks of "vexp's tools" only when it can.
|
|
3650
|
+
let claudeEntryStarts = true;
|
|
2595
3651
|
try {
|
|
2596
3652
|
const cfg = JSON.parse(fs.readFileSync(claudePath, "utf-8"));
|
|
2597
3653
|
const v = cfg?.mcpServers?.vexp;
|
|
2598
3654
|
if (!v)
|
|
2599
3655
|
line(OK, "no user-scope vexp entry");
|
|
2600
|
-
else
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
3656
|
+
else {
|
|
3657
|
+
// Present is not the same as startable (see machineEntryCheck).
|
|
3658
|
+
const entry = machineEntryCheck({ agent: "Claude Code", command: v.command, args: v.args, install, root: ws.root });
|
|
3659
|
+
if (entry.fail) {
|
|
3660
|
+
claudeEntryStarts = false;
|
|
3661
|
+
line(BAD, entry.fail);
|
|
3662
|
+
}
|
|
3663
|
+
if (v.env?.VEXP_WORKSPACE)
|
|
3664
|
+
line(WARN, `vexp entry still pins VEXP_WORKSPACE=${v.env.VEXP_WORKSPACE} → run 'vexp setup' to de-pin (multi-session fix)`);
|
|
3665
|
+
else if (!entry.fail)
|
|
3666
|
+
line(OK, "vexp entry present, no VEXP_WORKSPACE pin (per-session targeting OK)");
|
|
3667
|
+
if (entry.note)
|
|
3668
|
+
emit({ level: NOTE, message: entry.note });
|
|
3669
|
+
}
|
|
2604
3670
|
}
|
|
2605
3671
|
catch {
|
|
2606
3672
|
line(OK, "no ~/.claude.json");
|
|
@@ -2840,9 +3906,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2840
3906
|
})();
|
|
2841
3907
|
if (!settings) {
|
|
2842
3908
|
// Only a finding for someone who actually uses Claude Code here.
|
|
2843
|
-
|
|
2844
|
-
? "no .claude/settings.json, but vexp MCP is configured for Claude Code — the orientation hooks were never installed. Run 'vexp setup' (use --dry-run first to see what it would write)."
|
|
2845
|
-
: "no .claude/settings.json (Claude Code not configured here)");
|
|
3909
|
+
emit(claudeNotSetUpFinding({ mcpConfigured, decision: decisionFor(readAgentConsent(), ws.root, "Claude Code"), install, root: ws.root, entryStarts: claudeEntryStarts }));
|
|
2846
3910
|
}
|
|
2847
3911
|
else {
|
|
2848
3912
|
// Hooks Claude Code cannot run on this PC for want of Git Bash: one line
|
|
@@ -2858,7 +3922,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
2858
3922
|
line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
|
|
2859
3923
|
}
|
|
2860
3924
|
else {
|
|
2861
|
-
line(WARN, `${w.event} (${w.label}) not installed —
|
|
3925
|
+
line(WARN, `${w.event} (${w.label}) not installed — to write it: ${setupAgentsRemedy(install, ["Claude Code"], ws.root)}.`);
|
|
2862
3926
|
}
|
|
2863
3927
|
continue;
|
|
2864
3928
|
}
|
|
@@ -3194,73 +4258,18 @@ async function doctorChecks(onWorkspace) {
|
|
|
3194
4258
|
}
|
|
3195
4259
|
}
|
|
3196
4260
|
}
|
|
3197
|
-
//
|
|
3198
|
-
// keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
|
|
3199
|
-
// stop it: the old process runs the old comparison. The only fix is to end
|
|
3200
|
-
// it, so doctor has to name it — with its pid, which the record now
|
|
3201
|
-
// carries. A tester spent a night on a :7821 server that came back with a
|
|
3202
|
-
// new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
|
|
3203
|
-
// was not installed anywhere: the LABEL came from a `vexp serve` resident
|
|
3204
|
-
// since before the upgrade, holding its own version in memory
|
|
3205
|
-
// (field report, 2026-09-06).
|
|
3206
|
-
const staleSupervisorLine = (rec) => {
|
|
3207
|
-
const v = supervisorVerdict(rec, CLI_VERSION, {
|
|
3208
|
-
parentOf: parentPid,
|
|
3209
|
-
alive: isAlive,
|
|
3210
|
-
});
|
|
3211
|
-
if (v.kind === "ok")
|
|
3212
|
-
return;
|
|
3213
|
-
if (v.kind === "unknown") {
|
|
3214
|
-
line(WARN, `cannot tell which build spawned that server — the record predates the field and its parent process could not be read.\n` +
|
|
3215
|
-
` If :${rec.port} keeps reverting to an older version, a resident 'vexp serve' is the cause: find it (ps) and end it.`);
|
|
3216
|
-
return;
|
|
3217
|
-
}
|
|
3218
|
-
const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
|
|
3219
|
-
const provenance = v.by
|
|
3220
|
-
? `running v${v.by}, not v${CLI_VERSION}`
|
|
3221
|
-
: `started before this CLI was installed — its version was not recorded, so it is a build older than v${CLI_VERSION}`;
|
|
3222
|
-
line(WARN, `that server was spawned by a ${who} ${provenance}` +
|
|
3223
|
-
(v.pid ? ` (pid ${v.pid}${v.alive ? ", still running" : ", already gone"})` : "") +
|
|
3224
|
-
`\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
|
|
3225
|
-
// Ending it is the whole remediation: login autostart (or the next
|
|
3226
|
-
// `vexp` command) starts a fresh supervisor, which reads the current
|
|
3227
|
-
// package.json and serves the current build. The tester who did this
|
|
3228
|
-
// had a correct server one second later without running anything
|
|
3229
|
-
// else — and the `vexp setup` this line used to recommend blocks on
|
|
3230
|
-
// an interactive prompt, which for the reader of a doctor line is a
|
|
3231
|
-
// hang, not a fix (field report, 2026-09-07).
|
|
3232
|
-
(v.alive
|
|
3233
|
-
? `\n end it FIRST — 'kill ${v.pid}' — before restarting any daemon.` +
|
|
3234
|
-
// The order is load-bearing and nothing said so: an old
|
|
3235
|
-
// supervisor's health loop rebuilds the daemon registry every 60s
|
|
3236
|
-
// from its own rows, so it deletes each daemon's registration as
|
|
3237
|
-
// fast as the daemon writes it. Restart the daemons first and they
|
|
3238
|
-
// come back invisible — and nobody restarts a daemon they have
|
|
3239
|
-
// just restarted (field report, 2026-09-08).
|
|
3240
|
-
`\n Its health loop deletes daemon registrations as fast as they are written, so a daemon` +
|
|
3241
|
-
`\n restarted before it is gone comes back invisible to 'vexp daemons'.`
|
|
3242
|
-
: ""));
|
|
3243
|
-
};
|
|
3244
|
-
// 6) HTTP MCP supervisor.
|
|
4261
|
+
// 6) HTTP MCP supervisor. Every decision is in httpServerFindings.
|
|
3245
4262
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
3246
4263
|
try {
|
|
3247
4264
|
const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
|
|
3248
|
-
const
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
// saw two vexp servers with different tool counts and could not tell why.
|
|
3257
|
-
if (alive && rec.version && rec.version !== CLI_VERSION) {
|
|
3258
|
-
line(WARN, `the HTTP MCP server on :${rec.port} is v${rec.version} but this CLI is v${CLI_VERSION} — HTTP clients get the old build until it is replaced — run 'vexp setup' in the WORKSPACE ROOT (the directory holding .vexp/) and the current CLI takes it over`);
|
|
3259
|
-
staleSupervisorLine(rec);
|
|
3260
|
-
}
|
|
3261
|
-
else if (alive && !rec.version) {
|
|
3262
|
-
line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup' in the WORKSPACE ROOT so the current CLI replaces it and HTTP clients get v${CLI_VERSION}`);
|
|
3263
|
-
}
|
|
4265
|
+
for (const f of httpServerFindings(rec, {
|
|
4266
|
+
cliVersion: CLI_VERSION,
|
|
4267
|
+
platform: process.platform,
|
|
4268
|
+
install,
|
|
4269
|
+
codexHttp,
|
|
4270
|
+
io: { parentOf: parentPid, alive: isAlive, commandLineOf: processCommandLine },
|
|
4271
|
+
}))
|
|
4272
|
+
emit(f);
|
|
3264
4273
|
}
|
|
3265
4274
|
catch {
|
|
3266
4275
|
line(OK, "not running (no mcp.pid)");
|
|
@@ -3274,7 +4283,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3274
4283
|
if (warns > 0 || fails > 0) {
|
|
3275
4284
|
// The Codex half only where Codex was chosen: to anyone else it read as
|
|
3276
4285
|
// one more reason to set Codex up (field report, 2026-09-24).
|
|
3277
|
-
console.log(chalk.dim(`
|
|
4286
|
+
console.log(chalk.dim(` ${summaryTips(install, codexUse.used)}`));
|
|
3278
4287
|
}
|
|
3279
4288
|
console.log("");
|
|
3280
4289
|
// Scripts and CI read the exit code, not the colors: any FAIL exits 1.
|