vexp-cli 3.3.2 → 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +187 -25
- package/dist/autostart.js +13 -5
- package/dist/cli.js +134 -11
- package/dist/daemon-upgrade.js +337 -0
- package/dist/doctor.js +1216 -148
- package/dist/mcp-supervisor.js +98 -7
- package/dist/stop-all.js +116 -0
- package/mcp/mcp-server.cjs +51 -48
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -130,6 +130,25 @@ function codexHomeArtefacts() {
|
|
|
130
130
|
const codexHome = process.env.CODEX_HOME ? path.resolve(process.env.CODEX_HOME) : path.join(os.homedir(), ".codex");
|
|
131
131
|
return ["auth.json", "sessions", "session_index.jsonl", "history.jsonl", "installation_id", "version.json"].map((f) => path.join(codexHome, f));
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Does this machine show Windsurf (Devin Desktop since 2026-06-02)? What the
|
|
135
|
+
* tool itself keeps in its folder under ~/.codeium, for either channel:
|
|
136
|
+
* anything there beside mcp_config.json and the backups of it. That one file
|
|
137
|
+
* proves nothing: vexp writes it, and until 3.3.3 created it with its folder
|
|
138
|
+
* on machines that never had the tool. Lockstep with the extension, which
|
|
139
|
+
* can also ask the editor it runs in; a terminal has no editor to ask, and
|
|
140
|
+
* `--agents Windsurf` is there for that case.
|
|
141
|
+
*/
|
|
142
|
+
function windsurfOnThisMachine() {
|
|
143
|
+
return ["windsurf", "windsurf-next"].some((channel) => {
|
|
144
|
+
try {
|
|
145
|
+
return fs.readdirSync(path.join(os.homedir(), ".codeium", channel)).some((e) => !e.startsWith("mcp_config.json"));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
133
152
|
const AGENT_DETECTORS = [
|
|
134
153
|
{
|
|
135
154
|
agent: "Claude Code",
|
|
@@ -155,10 +174,21 @@ const AGENT_DETECTORS = [
|
|
|
155
174
|
},
|
|
156
175
|
{
|
|
157
176
|
agent: "Windsurf",
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
177
|
+
// What Cascade itself reads: its rules folder, the legacy root file, and
|
|
178
|
+
// the rules of Devin Desktop (Windsurf's name since 2026-06-02). The
|
|
179
|
+
// `.windsurf` and `.devin` folders alone are a hint: repositories ship
|
|
180
|
+
// them for the teammates who use the tool, and on a checkout of one a
|
|
181
|
+
// setup wrote the rule, .devin/mcp_config.json and a machine-wide
|
|
182
|
+
// ~/.codeium/windsurf/mcp_config.json on a machine with no Windsurf
|
|
183
|
+
// (field report, 2026-10-08). The rules prove the project, not the
|
|
184
|
+
// person: they set Windsurf up without a question only where the machine
|
|
185
|
+
// shows Windsurf as well. Lockstep with the extension, which changed
|
|
186
|
+
// first: for a day the two planners disagreed, and `vexp setup` wrote
|
|
187
|
+
// what the extension had learnt to ask about (review, 2026-10-08).
|
|
188
|
+
detectPath: ".windsurf/rules",
|
|
189
|
+
detectPaths: [".windsurfrules", ".devin/rules"],
|
|
190
|
+
detectHints: [".windsurf", ".devin"],
|
|
191
|
+
onThisMachine: windsurfOnThisMachine,
|
|
162
192
|
configFile: ".windsurf/rules/vexp.md",
|
|
163
193
|
templateName: "windsurf",
|
|
164
194
|
// MCP is NOT project-level here: Cascade reads only the machine-global
|
|
@@ -193,7 +223,16 @@ const AGENT_DETECTORS = [
|
|
|
193
223
|
},
|
|
194
224
|
{
|
|
195
225
|
agent: "GitHub Copilot",
|
|
196
|
-
|
|
226
|
+
// What Copilot itself reads: the repository instructions (with text of
|
|
227
|
+
// the user's in them: the file is also the one vexp writes), the
|
|
228
|
+
// path-specific instructions and the prompt files. `.github` alone is a
|
|
229
|
+
// hint: most repositories have it for their CI workflows, and even an
|
|
230
|
+
// empty one got copilot-instructions.md and .vscode/mcp.json written
|
|
231
|
+
// unasked (same field report as Windsurf above). Lockstep with the
|
|
232
|
+
// extension.
|
|
233
|
+
detectPath: ".github/copilot-instructions.md",
|
|
234
|
+
detectPaths: [".github/instructions", ".github/prompts"],
|
|
235
|
+
detectHints: [".github"],
|
|
197
236
|
configFile: ".github/copilot-instructions.md",
|
|
198
237
|
templateName: "copilot",
|
|
199
238
|
},
|
|
@@ -310,9 +349,10 @@ const AGENT_DETECTORS = [
|
|
|
310
349
|
* such a file proves only that vexp ran here: a `.clinerules` written because
|
|
311
350
|
* Cline's VS Code storage exists "detected" Cline in that project at every
|
|
312
351
|
* start after, the loop AGENTS.md closed for Codex through 3.2.5. It counts
|
|
313
|
-
* only when someone put content of their own in it.
|
|
352
|
+
* only when someone put content of their own in it. Windsurf's rules folder
|
|
353
|
+
* is in the list for the same reason: vexp's own rule is a file in it.
|
|
314
354
|
*/
|
|
315
|
-
const VEXP_WRITTEN_MARKERS = new Set(AGENT_DETECTORS.map((d) => d.configFile));
|
|
355
|
+
const VEXP_WRITTEN_MARKERS = new Set([...AGENT_DETECTORS.map((d) => d.configFile), ".windsurf/rules"]);
|
|
316
356
|
/** `text` without vexp's `<!-- vexp ... -->` … `<!-- /vexp -->` block (to
|
|
317
357
|
* the end of the file for the old unterminated format). */
|
|
318
358
|
function withoutVexpSection(text) {
|
|
@@ -326,6 +366,22 @@ function withoutVexpSection(text) {
|
|
|
326
366
|
const end = endIdx === -1 ? text.length : endIdx + VEXP_MARKER_END.length;
|
|
327
367
|
return text.slice(0, start) + text.slice(end);
|
|
328
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* Is `rest`, what a file holds beside vexp's block, the front matter vexp
|
|
371
|
+
* itself puts above that block and nothing else? The Windsurf and Cursor
|
|
372
|
+
* rules open with it (it is what makes the rule always on), and its
|
|
373
|
+
* description line has been vexp's own since the first one written. Left
|
|
374
|
+
* alone it is not the user's text: counted as such, a
|
|
375
|
+
* `.windsurf/rules/vexp.md` that vexp wrote would prove Windsurf at every
|
|
376
|
+
* setup after.
|
|
377
|
+
*/
|
|
378
|
+
function isVexpFrontMatter(rest) {
|
|
379
|
+
const lines = rest.trim().split(/\r?\n/);
|
|
380
|
+
return (lines.length >= 2 &&
|
|
381
|
+
lines[0] === "---" &&
|
|
382
|
+
lines.indexOf("---", 1) === lines.length - 1 &&
|
|
383
|
+
lines.some((l) => l.startsWith("description: vexp ")));
|
|
384
|
+
}
|
|
329
385
|
/** Does a vexp-written marker hold anything of the user's? Unreadable counts
|
|
330
386
|
* as yes: when in doubt, detection behaves as it always did. */
|
|
331
387
|
function hasOwnContent(p) {
|
|
@@ -348,7 +404,11 @@ function hasOwnContent(p) {
|
|
|
348
404
|
return entries.some((e) => e !== "vexp.md" || hasOwnContent(path.join(p, e)));
|
|
349
405
|
}
|
|
350
406
|
try {
|
|
351
|
-
|
|
407
|
+
const text = fs.readFileSync(p, "utf-8");
|
|
408
|
+
const rest = withoutVexpSection(text);
|
|
409
|
+
if (rest !== text && isVexpFrontMatter(rest))
|
|
410
|
+
return false;
|
|
411
|
+
return rest.trim() !== "";
|
|
352
412
|
}
|
|
353
413
|
catch {
|
|
354
414
|
return true;
|
|
@@ -362,21 +422,33 @@ function isDirectory(p) {
|
|
|
362
422
|
return false;
|
|
363
423
|
}
|
|
364
424
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const inProject = markers.some((m) => {
|
|
425
|
+
/** The marker of `d` this project holds, if any: something the agent itself reads. */
|
|
426
|
+
function projectMarker(d, workspaceRoot) {
|
|
427
|
+
return [d.detectPath, ...(d.detectPaths ?? [])].find((m) => {
|
|
369
428
|
const p = path.join(workspaceRoot, m);
|
|
370
429
|
if (!fs.existsSync(p))
|
|
371
430
|
return false;
|
|
372
431
|
return !VEXP_WRITTEN_MARKERS.has(m) || hasOwnContent(p);
|
|
373
432
|
});
|
|
374
|
-
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* What in this project hints at `d` without showing that its user runs it:
|
|
436
|
+
* the agent's own files, which classifyAgent takes as a marker wherever the
|
|
437
|
+
* machine shows the agent, or a folder the agent shares with other things.
|
|
438
|
+
*/
|
|
439
|
+
function projectHint(d, workspaceRoot) {
|
|
440
|
+
return projectMarker(d, workspaceRoot) ?? (d.detectHints ?? []).find((h) => isDirectory(path.join(workspaceRoot, h)));
|
|
441
|
+
}
|
|
442
|
+
/** Where `d` is found for this workspace, if anywhere. */
|
|
443
|
+
export function classifyAgent(d, workspaceRoot) {
|
|
444
|
+
if (projectMarker(d, workspaceRoot) && (!d.onThisMachine || d.onThisMachine()))
|
|
375
445
|
return "project";
|
|
376
446
|
if (d.detectHome && isDirectory(path.join(os.homedir(), d.detectHome)))
|
|
377
447
|
return "machine";
|
|
378
448
|
if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p)))
|
|
379
449
|
return "machine";
|
|
450
|
+
if (projectHint(d, workspaceRoot))
|
|
451
|
+
return "hint";
|
|
380
452
|
return undefined;
|
|
381
453
|
}
|
|
382
454
|
/** Every agent found for this workspace, in detector order. */
|
|
@@ -392,13 +464,19 @@ export function classifyAgents(workspaceRoot) {
|
|
|
392
464
|
/**
|
|
393
465
|
* What a setup does without asking. Project markers and earlier "yes"
|
|
394
466
|
* answers are configured as they always were; traces on the machine alone
|
|
395
|
-
* (Codex's login, Antigravity's folder, Cline's storage, ~/.claude)
|
|
396
|
-
*
|
|
397
|
-
*
|
|
467
|
+
* (Codex's login, Antigravity's folder, Cline's storage, ~/.claude) and a
|
|
468
|
+
* hint in the project (a bare `.github`, a `.windsurf` folder) wait for the
|
|
469
|
+
* user. Nothing vexp wrote counts as an answer: a 3.2.5 user whose Codex
|
|
470
|
+
* setup came from AGENTS.md is asked once, like everyone else, and so is a
|
|
471
|
+
* 3.3.3 user whose Copilot setup came from a `.github` folder.
|
|
398
472
|
*/
|
|
399
473
|
export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
|
|
400
474
|
const plan = { configure: [], offer: [], declined: [], kinds: {} };
|
|
401
475
|
const key = consentKey(workspaceRoot);
|
|
476
|
+
// The extension asks one question after another, and one left open keeps
|
|
477
|
+
// the rest from being seen: a tool the machine shows goes before one the
|
|
478
|
+
// project only hints at. The same order here, so the two plans compare.
|
|
479
|
+
const hinted = [];
|
|
402
480
|
for (const d of AGENT_DETECTORS) {
|
|
403
481
|
const kind = classifyAgent(d, workspaceRoot);
|
|
404
482
|
if (kind)
|
|
@@ -406,9 +484,16 @@ export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
|
|
|
406
484
|
const decision = decisionForKey(consent, key, d.agent);
|
|
407
485
|
if (kind === "project" || decision === "yes")
|
|
408
486
|
plan.configure.push(d.agent);
|
|
487
|
+
else if (decision === "no" || decision === "never") {
|
|
488
|
+
if (kind)
|
|
489
|
+
plan.declined.push(d.agent);
|
|
490
|
+
}
|
|
409
491
|
else if (kind === "machine")
|
|
410
|
-
|
|
492
|
+
plan.offer.push(d.agent);
|
|
493
|
+
else if (kind === "hint")
|
|
494
|
+
hinted.push(d.agent);
|
|
411
495
|
}
|
|
496
|
+
plan.offer.push(...hinted);
|
|
412
497
|
return plan;
|
|
413
498
|
}
|
|
414
499
|
/** The command that configures `agents` here and does nothing else: `vexp
|
|
@@ -429,7 +514,13 @@ export function setupAgentsCommand(agents, dir) {
|
|
|
429
514
|
export function nonInteractiveAgentChoice(plan, dir) {
|
|
430
515
|
const notes = [];
|
|
431
516
|
if (plan.offer.length > 0) {
|
|
432
|
-
|
|
517
|
+
// A hint is in the project, not on the machine: each said as what it is.
|
|
518
|
+
const hinted = plan.offer.filter((a) => plan.kinds[a] === "hint");
|
|
519
|
+
const onMachine = plan.offer.filter((a) => plan.kinds[a] !== "hint");
|
|
520
|
+
if (onMachine.length > 0)
|
|
521
|
+
notes.push(`Found on this machine, not set up for this project: ${onMachine.join(", ")}.`);
|
|
522
|
+
if (hinted.length > 0)
|
|
523
|
+
notes.push(`Hinted at by this project's folders, which alone do not show it uses ${hinted.length === 1 ? "it" : "them"}: ${hinted.join(", ")}. Not set up.`);
|
|
433
524
|
notes.push(`To set ${plan.offer.length === 1 ? "it" : "them"} up here: ${setupAgentsCommand(plan.offer, dir)}`);
|
|
434
525
|
}
|
|
435
526
|
if (plan.configure.length === 0) {
|
|
@@ -1252,7 +1343,7 @@ export function resolveNodeCommand() {
|
|
|
1252
1343
|
if (execPath && (execBase === "node" || execBase === "node.exe")) {
|
|
1253
1344
|
try {
|
|
1254
1345
|
fs.accessSync(execPath, fs.constants.X_OK);
|
|
1255
|
-
return execPath;
|
|
1346
|
+
return stableNodeAlias(execPath, nodeCandidates(process.platform, os.homedir(), process.env));
|
|
1256
1347
|
}
|
|
1257
1348
|
catch { /* not executable */ }
|
|
1258
1349
|
}
|
|
@@ -1265,6 +1356,44 @@ export function resolveNodeCommand() {
|
|
|
1265
1356
|
return fromShell;
|
|
1266
1357
|
return "node";
|
|
1267
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* The spelling of this node that outlives an upgrade.
|
|
1361
|
+
*
|
|
1362
|
+
* Node reports `process.execPath` with every link followed, and a package
|
|
1363
|
+
* manager keeps each build in a directory of its own: under Homebrew that is
|
|
1364
|
+
* `<prefix>/Cellar/node/<build>/bin/node`, which the next `brew upgrade node`
|
|
1365
|
+
* removes. An MCP entry written with it stops starting on that day, and
|
|
1366
|
+
* nothing on our side says why (field report, 2026-10-10). The manager's own
|
|
1367
|
+
* link to the same file is the one to write: `<prefix>/bin/node`, or
|
|
1368
|
+
* `<prefix>/opt/<formula>/bin/node` for a formula not linked into bin. Any
|
|
1369
|
+
* other candidate that resolves to the same file serves as well; with no
|
|
1370
|
+
* such link, the path as it is.
|
|
1371
|
+
*
|
|
1372
|
+
* Lockstep with the extension, which carries the same function.
|
|
1373
|
+
*/
|
|
1374
|
+
export function stableNodeAlias(execPath, candidates, real = fs.realpathSync) {
|
|
1375
|
+
let target;
|
|
1376
|
+
try {
|
|
1377
|
+
target = real(execPath);
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
return execPath;
|
|
1381
|
+
}
|
|
1382
|
+
const keg = /^(.*)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/.exec(target);
|
|
1383
|
+
const links = keg
|
|
1384
|
+
? [path.join(keg[1], "bin", "node"), path.join(keg[1], "opt", keg[2], "bin", "node"), ...candidates]
|
|
1385
|
+
: candidates;
|
|
1386
|
+
for (const link of links) {
|
|
1387
|
+
if (link === target)
|
|
1388
|
+
continue;
|
|
1389
|
+
try {
|
|
1390
|
+
if (real(link) === target)
|
|
1391
|
+
return link;
|
|
1392
|
+
}
|
|
1393
|
+
catch { /* no such link */ }
|
|
1394
|
+
}
|
|
1395
|
+
return execPath;
|
|
1396
|
+
}
|
|
1268
1397
|
/**
|
|
1269
1398
|
* Absolute locations a node binary is commonly installed at, newest version
|
|
1270
1399
|
* manager entries first. A GUI-launched editor (Dock, Start menu) has the
|
|
@@ -1381,6 +1510,32 @@ export function isBareInterpreter(entry) {
|
|
|
1381
1510
|
command = command.path;
|
|
1382
1511
|
return typeof command === "string" && command.length > 0 && !/[\\/]/.test(command);
|
|
1383
1512
|
}
|
|
1513
|
+
/**
|
|
1514
|
+
* An entry whose interpreter is the path of ONE build of node, on a machine
|
|
1515
|
+
* where a link that outlives the build exists. Every setup before
|
|
1516
|
+
* `stableNodeAlias` wrote such an entry under Homebrew, and the path is
|
|
1517
|
+
* still there on the day setup runs again, so `vexpEntryStillResolves`
|
|
1518
|
+
* adopted it as already working and it stayed until the upgrade that
|
|
1519
|
+
* removes it (field report, 2026-10-10: a Claude Code entry). No writer
|
|
1520
|
+
* keeps one: the next `vexp setup` replaces it. An entry already written
|
|
1521
|
+
* with the link, a bare `node` and a custom launcher are none of this.
|
|
1522
|
+
*/
|
|
1523
|
+
export function pinsOneNodeBuild(entry) {
|
|
1524
|
+
const command = entryCommand(entry);
|
|
1525
|
+
if (!command || !path.isAbsolute(command))
|
|
1526
|
+
return false;
|
|
1527
|
+
// A command that itself goes through a link is not a build's own path,
|
|
1528
|
+
// whatever else points at the same file: the manager's second link
|
|
1529
|
+
// (<prefix>/opt/node/bin/node beside <prefix>/bin/node), one the user made.
|
|
1530
|
+
try {
|
|
1531
|
+
if (fs.realpathSync(command) !== command)
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
return stableNodeAlias(command, nodeCandidates(process.platform, os.homedir(), process.env)) !== command;
|
|
1538
|
+
}
|
|
1384
1539
|
/**
|
|
1385
1540
|
* Adopt a foreign entry only when it resolves AND pins its interpreter. Used by
|
|
1386
1541
|
* the GUI-editor writers (VS Code/Copilot, Cursor/Windsurf/Kiro/Trae, Zed),
|
|
@@ -1388,7 +1543,7 @@ export function isBareInterpreter(entry) {
|
|
|
1388
1543
|
* (opencode, Claude Code) keep adopting a bare interpreter — it works there.
|
|
1389
1544
|
*/
|
|
1390
1545
|
function adoptableEntry(entry) {
|
|
1391
|
-
return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
|
|
1546
|
+
return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry) && !pinsOneNodeBuild(entry);
|
|
1392
1547
|
}
|
|
1393
1548
|
/**
|
|
1394
1549
|
* Is `recorded` strictly older than `mine`? Copy of the extension's
|
|
@@ -2610,8 +2765,9 @@ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
|
2610
2765
|
// A working entry is already here — either another vexp install, or the user's
|
|
2611
2766
|
// own hand-written block (this bug made several people write one). Don't fight
|
|
2612
2767
|
// it: only the install path may differ, and `envMatches` still gates on the
|
|
2613
|
-
// VEXP_WORKSPACE pin so a moved project is repinned normally.
|
|
2614
|
-
|
|
2768
|
+
// VEXP_WORKSPACE pin so a moved project is repinned normally. An entry that
|
|
2769
|
+
// names one build of node is not kept (pinsOneNodeBuild).
|
|
2770
|
+
if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev) && !pinsOneNodeBuild(prev))
|
|
2615
2771
|
return null;
|
|
2616
2772
|
mcp["vexp"] = { type: "local", command, env, enabled: true };
|
|
2617
2773
|
cfg.mcp = mcp;
|
|
@@ -2651,8 +2807,9 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
|
|
|
2651
2807
|
// Another vexp install already left a working entry here — don't fight it.
|
|
2652
2808
|
// Kilo's shape is `command: ["node", "<script>"]`. Only the install path may
|
|
2653
2809
|
// differ: `envMatches` still gates on the VEXP_WORKSPACE pin, and `enabled`
|
|
2654
|
-
// must be true or the user turned it off deliberately.
|
|
2655
|
-
|
|
2810
|
+
// must be true or the user turned it off deliberately. An entry that names
|
|
2811
|
+
// one build of node is not kept (pinsOneNodeBuild).
|
|
2812
|
+
if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev) && !pinsOneNodeBuild(prev))
|
|
2656
2813
|
return null;
|
|
2657
2814
|
mcp["vexp"] = { type: "local", command, env, enabled: true };
|
|
2658
2815
|
cfg.mcp = mcp;
|
|
@@ -2765,7 +2922,12 @@ version) {
|
|
|
2765
2922
|
// 3.2.5 machines still pointing at 3.2.4, 2026-09-24). Take over an older
|
|
2766
2923
|
// one, keep a newer one, and keep anything we cannot version (the npm
|
|
2767
2924
|
// CLI's own path, a custom launcher). Lockstep with the extension.
|
|
2768
|
-
|
|
2925
|
+
//
|
|
2926
|
+
// Nor is an entry kept whose node is one build's own path while a link
|
|
2927
|
+
// that outlives the build exists: it resolves today and stops at the next
|
|
2928
|
+
// `brew upgrade node`. The entry this was reported on is this one (field
|
|
2929
|
+
// report, 2026-10-10; pinsOneNodeBuild).
|
|
2930
|
+
if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing) && !pinsOneNodeBuild(existing)) {
|
|
2769
2931
|
const theirs = vexpInstallVersion(existing);
|
|
2770
2932
|
const ours = vexpInstallVersion({ command: desiredCommand, args: desiredArgs }) ?? version;
|
|
2771
2933
|
if (!theirs || !ours || !isOlderVexpVersion(theirs, ours))
|
package/dist/autostart.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { execFileSync, spawnSync } from "child_process";
|
|
5
|
-
import { migrateClaudeCodeUnpin } from "./agent-config.js";
|
|
5
|
+
import { migrateClaudeCodeUnpin, nodeCandidates, stableNodeAlias } from "./agent-config.js";
|
|
6
6
|
const LABEL = "io.vexp.serve";
|
|
7
7
|
const BEGIN_MARKER = "# vexp-autostart-begin";
|
|
8
8
|
const END_MARKER = "# vexp-autostart-end";
|
|
@@ -14,15 +14,23 @@ function installMarkerPath() {
|
|
|
14
14
|
return path.join(homedir(), ".vexp", INSTALL_MARKER_FILE);
|
|
15
15
|
}
|
|
16
16
|
/** Resolve the launcher the OS should execute. Prefers the `vexp` bin in
|
|
17
|
-
* PATH; falls back to node+dist/cli.cjs if invoked from a dev checkout.
|
|
18
|
-
|
|
17
|
+
* PATH; falls back to node+dist/cli.cjs if invoked from a dev checkout.
|
|
18
|
+
*
|
|
19
|
+
* The node is not written as Node reports it. Under Homebrew that path
|
|
20
|
+
* names one build (<prefix>/Cellar/node/<build>/bin/node), and a login item
|
|
21
|
+
* written with it stops starting at the next `brew upgrade node`, the same
|
|
22
|
+
* way an MCP entry did (field report, 2026-10-10; stableNodeAlias). */
|
|
23
|
+
export function resolveLauncher(execPath = process.execPath, script = process.argv[1]) {
|
|
19
24
|
if (process.env.VEXP_AUTOSTART_LAUNCHER) {
|
|
20
25
|
const parts = process.env.VEXP_AUTOSTART_LAUNCHER.split(" ");
|
|
21
26
|
return { bin: parts[0], args: [...parts.slice(1), "serve"] };
|
|
22
27
|
}
|
|
23
28
|
// `vexp` launches the commander CLI with argv-dispatch; argv[2] = "serve".
|
|
24
|
-
const hint =
|
|
25
|
-
? {
|
|
29
|
+
const hint = execPath && script
|
|
30
|
+
? {
|
|
31
|
+
bin: stableNodeAlias(execPath, nodeCandidates(process.platform, os.homedir(), process.env)),
|
|
32
|
+
args: [script, "serve"],
|
|
33
|
+
}
|
|
26
34
|
: { bin: "vexp", args: ["serve"] };
|
|
27
35
|
// On Windows, prefer the global shim when available so the user's PATH
|
|
28
36
|
// can be used without baking in an absolute node path.
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ import { runDoctor } from "./doctor.js";
|
|
|
22
22
|
import { socketPathFor } from "./socket-path.js";
|
|
23
23
|
import { isStateHome } from "./state-home.js";
|
|
24
24
|
import { forgetRegistryRows, removeIndexArtifacts } from "./forget.js";
|
|
25
|
+
import { parseDaemonRows, daemonsToStop, engineSaid, daemonWasStopped, stopNotes, supervisorNoteDue, stopAllSummary } from "./stop-all.js";
|
|
26
|
+
import { replaceOlderDaemon, parseEngineVersion, commandMayReplaceDaemon } from "./daemon-upgrade.js";
|
|
25
27
|
import { mutableOutput, askSecretOn } from "./secret-prompt.js";
|
|
26
28
|
import { isTraceEnabled } from "./trace.js";
|
|
27
29
|
import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
|
|
@@ -110,7 +112,11 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
110
112
|
}
|
|
111
113
|
// Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
|
|
112
114
|
// internally idempotent — no duplicate processes across invocations.
|
|
113
|
-
|
|
115
|
+
// A daemon that is up and older than this engine is replaced here, except
|
|
116
|
+
// before the commands commandMayReplaceDaemon names.
|
|
117
|
+
await ensureBootstrap(eff.root, binaryPath, {
|
|
118
|
+
replaceOlder: commandMayReplaceDaemon(actionCmd.name(), actionCmd.opts()),
|
|
119
|
+
});
|
|
114
120
|
});
|
|
115
121
|
// ────────────────────────────────────────────────────
|
|
116
122
|
// Resolve the platform binary (no network — bundled via npm)
|
|
@@ -256,18 +262,60 @@ function isDaemonPidAlive(workspaceRoot) {
|
|
|
256
262
|
return { alive: false };
|
|
257
263
|
}
|
|
258
264
|
}
|
|
265
|
+
/** The version of the engine at `binaryPath`, from its own `--version`. */
|
|
266
|
+
function engineVersionOf(binaryPath) {
|
|
267
|
+
const r = spawnSync(binaryPath, ["--version"], {
|
|
268
|
+
encoding: "utf-8",
|
|
269
|
+
env: binaryEnv(binaryPath),
|
|
270
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
271
|
+
timeout: 2_500,
|
|
272
|
+
windowsHide: true,
|
|
273
|
+
});
|
|
274
|
+
return r.status === 0 ? parseEngineVersion(r.stdout ?? "") : undefined;
|
|
275
|
+
}
|
|
259
276
|
/**
|
|
260
277
|
* Ensure the vexp-core daemon is running for the given workspace. Fast when
|
|
261
278
|
* it's already up (single socket probe). Idempotent lato Rust (PID file
|
|
262
279
|
* check + `kill -0`), so re-invoking when up is a no-op.
|
|
263
280
|
*
|
|
264
|
-
*
|
|
281
|
+
* With `replaceOlder`, a daemon that is up and older than this engine is
|
|
282
|
+
* restarted (daemon-upgrade.ts has the rules). "Up" used to be the end of
|
|
283
|
+
* it: after `npm install -g vexp-cli@latest` the daemon of every project
|
|
284
|
+
* kept serving the build it had started with (field report, 2026-10-10).
|
|
285
|
+
* The probe is then the daemon's own `index_status`, one connection that
|
|
286
|
+
* says both that it is up and what it is; the version is held against
|
|
287
|
+
* CLI_VERSION first, so the engine is asked for its own only when the
|
|
288
|
+
* daemon is behind.
|
|
289
|
+
*
|
|
290
|
+
* Silent on success, but for one dim line when a daemon was replaced. On
|
|
291
|
+
* failure prints a one-liner pointing at the log.
|
|
265
292
|
* Honors VEXP_NO_AUTOSTART=1 as opt-out.
|
|
266
293
|
*/
|
|
267
|
-
async function ensureDaemonRunning(workspaceRoot, binaryPath) {
|
|
294
|
+
async function ensureDaemonRunning(workspaceRoot, binaryPath, opts = {}) {
|
|
268
295
|
if (process.env.VEXP_NO_AUTOSTART === "1")
|
|
269
296
|
return;
|
|
270
|
-
|
|
297
|
+
const socketPath = socketPathFor(workspaceRoot);
|
|
298
|
+
if (opts.replaceOlder) {
|
|
299
|
+
const outcome = await replaceOlderDaemon({
|
|
300
|
+
workspace: workspaceRoot,
|
|
301
|
+
socket: socketPath,
|
|
302
|
+
launcherVersion: CLI_VERSION,
|
|
303
|
+
engineVersion: () => engineVersionOf(binaryPath),
|
|
304
|
+
restart: (dir) => runBinaryAsync(binaryPath, ["daemon-cmd", "restart"], { silent: true, cwd: dir }),
|
|
305
|
+
// Not said when it is not true: a daemon that answers afterwards
|
|
306
|
+
// and is still the older one was not replaced.
|
|
307
|
+
confirm: true,
|
|
308
|
+
});
|
|
309
|
+
if (outcome.replaced) {
|
|
310
|
+
console.error(chalk.dim(`vexp daemon of this workspace restarted: it was v${outcome.running}, this engine is v${outcome.engine}`));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// It answered and stays as it is. Anything else (no answer, a restart
|
|
314
|
+
// that stopped it and could not start it) goes on to the probe below.
|
|
315
|
+
if (outcome.up)
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (await isSocketAlive(socketPath))
|
|
271
319
|
return;
|
|
272
320
|
try {
|
|
273
321
|
await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], {
|
|
@@ -284,7 +332,9 @@ async function ensureDaemonRunning(workspaceRoot, binaryPath) {
|
|
|
284
332
|
* Full lazy bootstrap for any `vexp` invocation. Each step is already
|
|
285
333
|
* idempotent on its own — calling this from multiple places (preAction,
|
|
286
334
|
* REPL, setup) can never spawn duplicates:
|
|
287
|
-
* - `ensureDaemonRunning` → socket probe, skips spawn if alive
|
|
335
|
+
* - `ensureDaemonRunning` → socket probe, skips spawn if alive; with
|
|
336
|
+
* `replaceOlder`, restarts a daemon older than this engine (at most once
|
|
337
|
+
* in ten minutes per workspace)
|
|
288
338
|
* - `ensureMcpHttpServer` → ~/.vexp/mcp.pid + port probe, no-op if live
|
|
289
339
|
*
|
|
290
340
|
* The point of this wrapper is to ensure users who upgraded via
|
|
@@ -300,10 +350,10 @@ async function ensureDaemonRunning(workspaceRoot, binaryPath) {
|
|
|
300
350
|
*
|
|
301
351
|
* Honors VEXP_NO_AUTOSTART=1 as a global bypass.
|
|
302
352
|
*/
|
|
303
|
-
async function ensureBootstrap(workspaceRoot, binaryPath) {
|
|
353
|
+
async function ensureBootstrap(workspaceRoot, binaryPath, opts = {}) {
|
|
304
354
|
if (process.env.VEXP_NO_AUTOSTART === "1")
|
|
305
355
|
return;
|
|
306
|
-
await ensureDaemonRunning(workspaceRoot, binaryPath);
|
|
356
|
+
await ensureDaemonRunning(workspaceRoot, binaryPath, opts);
|
|
307
357
|
try {
|
|
308
358
|
await ensureMcpHttpServer({ owner: "cli" });
|
|
309
359
|
}
|
|
@@ -547,7 +597,7 @@ program
|
|
|
547
597
|
});
|
|
548
598
|
program
|
|
549
599
|
.command("daemons")
|
|
550
|
-
.description("List every vexp daemon on this machine: which workspace holds each concurrent-workspace slot, version, index size, uptime")
|
|
600
|
+
.description("List every vexp daemon on this machine: which workspace holds each concurrent-workspace slot, version, process id, memory, index size, uptime")
|
|
551
601
|
.option("--json", "Emit machine-readable JSON")
|
|
552
602
|
.action(async (opts) => {
|
|
553
603
|
const binaryPath = ensureBinary();
|
|
@@ -556,11 +606,83 @@ program
|
|
|
556
606
|
args.push("--json");
|
|
557
607
|
runBinary(binaryPath, args);
|
|
558
608
|
});
|
|
609
|
+
/**
|
|
610
|
+
* `vexp stop --all`: every live daemon on the machine, one after the other.
|
|
611
|
+
* The engine lists them and the engine stops each one, from its own
|
|
612
|
+
* workspace, exactly as `vexp stop <workspace>` does; one line per workspace
|
|
613
|
+
* with what it answered, then the count. Exits 1 when a daemon the listing
|
|
614
|
+
* saw could not be stopped.
|
|
615
|
+
*/
|
|
616
|
+
function stopAllDaemons(binaryPath) {
|
|
617
|
+
const listed = spawnSync(binaryPath, ["daemons", "--json"], {
|
|
618
|
+
encoding: "utf-8",
|
|
619
|
+
env: binaryEnv(binaryPath),
|
|
620
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
621
|
+
// The listing asks every registered socket for its status in turn.
|
|
622
|
+
timeout: 60_000,
|
|
623
|
+
});
|
|
624
|
+
const rows = listed.status === 0 ? parseDaemonRows(listed.stdout ?? "") : null;
|
|
625
|
+
if (!rows) {
|
|
626
|
+
const why = listed.error?.message ?? (engineSaid(listed.stderr, listed.stdout) || "its answer was not the listing");
|
|
627
|
+
console.error(chalk.red(`Could not list the daemons on this machine (vexp daemons --json): ${why}`));
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
const targets = daemonsToStop(rows);
|
|
631
|
+
if (targets.length === 0) {
|
|
632
|
+
console.log("No vexp daemon is running on this machine.");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const results = [];
|
|
636
|
+
for (const target of targets) {
|
|
637
|
+
let said;
|
|
638
|
+
let stopped = false;
|
|
639
|
+
if (!fs.existsSync(target.workspace)) {
|
|
640
|
+
// daemon-cmd stop finds the daemon through the folder it runs in.
|
|
641
|
+
said = `not stopped: the folder is gone, and the stop works from it${target.pid !== undefined ? ` (pid ${target.pid})` : ""}`;
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
const stop = spawnSync(binaryPath, ["daemon-cmd", "stop"], {
|
|
645
|
+
cwd: target.workspace,
|
|
646
|
+
encoding: "utf-8",
|
|
647
|
+
env: binaryEnv(binaryPath),
|
|
648
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
649
|
+
});
|
|
650
|
+
said = engineSaid(stop.stdout, stop.stderr) || (stop.error ? stop.error.message : "the engine said nothing");
|
|
651
|
+
stopped = daemonWasStopped(said, stop.status);
|
|
652
|
+
}
|
|
653
|
+
const result = { target, said, stopped };
|
|
654
|
+
results.push(result);
|
|
655
|
+
console.log(` ${target.workspace}: ${said}`);
|
|
656
|
+
for (const note of stopNotes(result))
|
|
657
|
+
console.log(chalk.dim(` ${note}`));
|
|
658
|
+
}
|
|
659
|
+
// Once, not per workspace: see the note `vexp stop <workspace>` prints.
|
|
660
|
+
if (supervisorNoteDue(results, (ws) => fs.existsSync(path.join(ws, ".vexp", "manifest.json")))) {
|
|
661
|
+
console.log(chalk.dim(" (a login supervisor restarts registered workspaces within a minute — to unregister one for good: vexp forget <workspace>)"));
|
|
662
|
+
}
|
|
663
|
+
const summary = stopAllSummary(results);
|
|
664
|
+
if (summary.ok) {
|
|
665
|
+
console.log(chalk.green(`✓ ${summary.line}`));
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
console.log(chalk.yellow(` ${summary.line}`));
|
|
669
|
+
process.exit(1);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
559
672
|
program
|
|
560
673
|
.command("stop [workspace]")
|
|
561
|
-
.description("Stop the vexp daemon of a workspace (default: current directory). Accepts any path shown by the concurrent-workspace cap message")
|
|
562
|
-
.
|
|
674
|
+
.description("Stop the vexp daemon of a workspace (default: current directory), or with --all every daemon on this machine. Accepts any path shown by the concurrent-workspace cap message")
|
|
675
|
+
.option("--all", "Stop every live vexp daemon on this machine, one after the other")
|
|
676
|
+
.action(async (workspace, opts) => {
|
|
677
|
+
if (opts.all && workspace !== undefined) {
|
|
678
|
+
console.error(`--all stops every daemon on this machine and takes no workspace: run 'vexp stop --all', or 'vexp stop "${workspace}"' for that one.`);
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
563
681
|
const binaryPath = ensureBinary();
|
|
682
|
+
if (opts.all) {
|
|
683
|
+
stopAllDaemons(binaryPath);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
564
686
|
const target = path.resolve(workspace ?? process.cwd());
|
|
565
687
|
if (!fs.existsSync(target)) {
|
|
566
688
|
console.error(`No such directory: ${target}`);
|
|
@@ -1604,7 +1726,8 @@ async function interactiveMode() {
|
|
|
1604
1726
|
const binaryPath = getBinaryPath();
|
|
1605
1727
|
// Effective root: in a connected secondary repo this boots the PARENT
|
|
1606
1728
|
// daemon (which serves this repo too) instead of a conflicting child one.
|
|
1607
|
-
|
|
1729
|
+
// Opening the menu is a use of the CLI: an older daemon is replaced.
|
|
1730
|
+
await ensureBootstrap(eff.root, binaryPath, { replaceOlder: true });
|
|
1608
1731
|
}
|
|
1609
1732
|
catch { /* banner will reflect actual state */ }
|
|
1610
1733
|
}
|