vexp-cli 3.3.3 → 3.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/mcp-supervisor.js
CHANGED
|
@@ -135,6 +135,97 @@ export function parentPid(pid) {
|
|
|
135
135
|
return undefined;
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The command line of a running process, or undefined when it cannot be read.
|
|
140
|
+
*
|
|
141
|
+
* A pid on record proves nothing about what runs under it today: the process
|
|
142
|
+
* that wrote the record may have ended, and the number may have been handed
|
|
143
|
+
* to another program since (a window reload, a reboot). Doctor named such a
|
|
144
|
+
* pid in a line that told the reader to end it, having tested only that some
|
|
145
|
+
* process had it (field report, 2026-10-08). Same contract as parentPid:
|
|
146
|
+
* unreadable is "cannot tell", never "yes".
|
|
147
|
+
*/
|
|
148
|
+
export function processCommandLine(pid) {
|
|
149
|
+
try {
|
|
150
|
+
if (process.platform === "linux") {
|
|
151
|
+
try {
|
|
152
|
+
const args = fs.readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean);
|
|
153
|
+
if (args.length > 0)
|
|
154
|
+
return args.join(" ");
|
|
155
|
+
}
|
|
156
|
+
catch { /* no /proc entry to read: ask ps */ }
|
|
157
|
+
}
|
|
158
|
+
const r = process.platform === "win32"
|
|
159
|
+
? spawnSync("powershell", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf-8", timeout: 4000, windowsHide: true })
|
|
160
|
+
: spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf-8", timeout: 4000 });
|
|
161
|
+
const line = String(r.stdout ?? "").trim();
|
|
162
|
+
return r.status === 0 && line ? line : undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Is the process behind a recorded supervisor pid still vexp? "vexp" when its
|
|
170
|
+
* command line names vexp (and `serve`, for a record a `vexp serve` wrote),
|
|
171
|
+
* "other" when it reads as another program, "unreadable" when it cannot be
|
|
172
|
+
* read. Only "vexp" may be named as a process to end.
|
|
173
|
+
*/
|
|
174
|
+
export function vexpProcessCheck(commandLine, owner) {
|
|
175
|
+
if (commandLine === undefined)
|
|
176
|
+
return "unreadable";
|
|
177
|
+
if (!/\bvexp\b/i.test(commandLine))
|
|
178
|
+
return "other";
|
|
179
|
+
return owner !== "serve" || /\bserve\b/.test(commandLine) ? "vexp" : "other";
|
|
180
|
+
}
|
|
181
|
+
/** How to end a process, as the reader would type it: `kill` is not a
|
|
182
|
+
* command in cmd.exe, and a console-less node ignores a taskkill without /F. */
|
|
183
|
+
export function endProcessCommand(pid, platform = process.platform) {
|
|
184
|
+
return platform === "win32" ? `taskkill /PID ${pid} /F` : `kill ${pid}`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* What to print when this CLI replaces an older MCP server on its port, or
|
|
188
|
+
* null for nothing.
|
|
189
|
+
*
|
|
190
|
+
* The warning was written for one case: a `vexp serve` left behind by an
|
|
191
|
+
* older install, whose 60 s loop takes the port back. It was printed for
|
|
192
|
+
* every record, so a CLI run through npx called the VS Code extension host
|
|
193
|
+
* "a supervisor from an older install" and asked for it to be ended first
|
|
194
|
+
* (field report, 2026-10-08). The extension has no loop on this server and
|
|
195
|
+
* leaves a newer one alone, so for its record there is nothing to end; and a
|
|
196
|
+
* pid is named only when the process behind it still reads as vexp. A server
|
|
197
|
+
* started by a `vexp` command (owner "cli") has nothing behind it that takes
|
|
198
|
+
* the port back either, and is replaced without a word.
|
|
199
|
+
*/
|
|
200
|
+
export function takeoverNotice(existing,
|
|
201
|
+
/** The process that started the server, when one is still running under that pid. */
|
|
202
|
+
boss, cliVersion, platform = process.platform) {
|
|
203
|
+
const was = existing.version ?? "pre-2.4";
|
|
204
|
+
if (existing.owner === "plugin") {
|
|
205
|
+
return (` replacing the MCP server on :${existing.port} (v${was}, started by the vexp extension of an IDE) with v${cliVersion} from this CLI.\n` +
|
|
206
|
+
` Nothing needs ending: the extension leaves a newer server alone. No agent that vexp sets up uses this server unless Codex's transport is set to http.`);
|
|
207
|
+
}
|
|
208
|
+
// A `vexp` command that started the server on its way has no loop on it:
|
|
209
|
+
// what follows is the story of a resident `vexp serve`, and of a record
|
|
210
|
+
// that does not say what wrote it.
|
|
211
|
+
if (existing.owner === "cli")
|
|
212
|
+
return null;
|
|
213
|
+
if (!boss)
|
|
214
|
+
return null;
|
|
215
|
+
const check = vexpProcessCheck(boss.commandLine, existing.owner);
|
|
216
|
+
if (check === "other")
|
|
217
|
+
return null; // the pid was handed to another program: the supervisor is gone
|
|
218
|
+
const head = ` ⚠ replacing the MCP server on :${existing.port} (was v${was}). `;
|
|
219
|
+
const why = `before restarting any daemon: its health loop deletes daemon registrations as fast as they are written.`;
|
|
220
|
+
if (check === "unreadable") {
|
|
221
|
+
return (head +
|
|
222
|
+
`It was started by a supervisor from an older install; whether that one still runs could not be read.\n` +
|
|
223
|
+
` If :${existing.port} reverts, 'vexp doctor' says how to find it: end it FIRST — ${why}`);
|
|
224
|
+
}
|
|
225
|
+
return (head +
|
|
226
|
+
`It was started by pid ${boss.pid}, a supervisor from an older install.\n` +
|
|
227
|
+
` If :${existing.port} reverts, end that process FIRST ('${endProcessCommand(boss.pid, platform)}') — ${why}`);
|
|
228
|
+
}
|
|
138
229
|
export function mcpBundleVersion(mcpPath) {
|
|
139
230
|
const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
|
|
140
231
|
try {
|
|
@@ -195,13 +286,13 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
195
286
|
// port revert with nothing to read. The supervisor is the parent of the
|
|
196
287
|
// server we are about to end; the recorded pid, when a recent build
|
|
197
288
|
// wrote it, says the same thing without a process lookup.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
289
|
+
// A server the IDE extension started has no such process: its record
|
|
290
|
+
// names the extension host, and nothing there takes the port back. Nor
|
|
291
|
+
// has one that a `vexp` command started on its way.
|
|
292
|
+
const bossPid = existing.owner === "plugin" || existing.owner === "cli" ? undefined : existing.spawnedByPid ?? parentPid(existing.pid);
|
|
293
|
+
const notice = takeoverNotice(existing, bossPid !== undefined && isPidAlive(bossPid) ? { pid: bossPid, commandLine: processCommandLine(bossPid) } : undefined, CLI_VERSION);
|
|
294
|
+
if (notice)
|
|
295
|
+
console.error(notice);
|
|
205
296
|
try {
|
|
206
297
|
process.kill(existing.pid, "SIGTERM");
|
|
207
298
|
}
|
package/dist/stop-all.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { socketPathFor } from "./socket-path.js";
|
|
2
|
+
/** The rows in what `daemons --json` printed, or null when that is not the
|
|
3
|
+
* listing (an engine that predates the command, an error in its place). */
|
|
4
|
+
export function parseDaemonRows(stdout) {
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = JSON.parse(stdout);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const list = parsed?.daemons;
|
|
13
|
+
if (!Array.isArray(list))
|
|
14
|
+
return null;
|
|
15
|
+
const rows = [];
|
|
16
|
+
for (const r of list) {
|
|
17
|
+
if (!r || typeof r.workspace !== "string" || !r.workspace || typeof r.socket !== "string" || !r.socket)
|
|
18
|
+
continue;
|
|
19
|
+
rows.push({
|
|
20
|
+
workspace: r.workspace,
|
|
21
|
+
socket: r.socket,
|
|
22
|
+
alive: r.alive === true,
|
|
23
|
+
...(typeof r.version === "string" ? { version: r.version } : {}),
|
|
24
|
+
...(typeof r.pid === "number" ? { pid: r.pid } : {}),
|
|
25
|
+
...(typeof r.exe === "string" && r.exe ? { exe: r.exe } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return rows;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The daemons behind the live rows, one per socket, in the engine's order.
|
|
32
|
+
*
|
|
33
|
+
* A connected repo is registered with the socket of the workspace that
|
|
34
|
+
* serves it, so two rows can be one daemon. It is stopped once, from the
|
|
35
|
+
* workspace the socket belongs to: `daemon-cmd stop` reads the pid file of
|
|
36
|
+
* the folder it runs in, and a connected repo has none.
|
|
37
|
+
*/
|
|
38
|
+
export function daemonsToStop(rows, socketOf = socketPathFor) {
|
|
39
|
+
const bySocket = new Map();
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
if (!row.alive)
|
|
42
|
+
continue;
|
|
43
|
+
const group = bySocket.get(row.socket);
|
|
44
|
+
if (group)
|
|
45
|
+
group.push(row);
|
|
46
|
+
else
|
|
47
|
+
bySocket.set(row.socket, [row]);
|
|
48
|
+
}
|
|
49
|
+
const targets = [];
|
|
50
|
+
for (const group of bySocket.values()) {
|
|
51
|
+
const owner = group.find((r) => socketOf(r.workspace) === r.socket) ?? group[0];
|
|
52
|
+
targets.push({
|
|
53
|
+
workspace: owner.workspace,
|
|
54
|
+
socket: owner.socket,
|
|
55
|
+
...(owner.pid !== undefined ? { pid: owner.pid } : {}),
|
|
56
|
+
...(owner.exe !== undefined ? { exe: owner.exe } : {}),
|
|
57
|
+
alsoServes: group.filter((r) => r !== owner).map((r) => r.workspace),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return targets;
|
|
61
|
+
}
|
|
62
|
+
/** A daemon running the engine inside an editor extension's folder
|
|
63
|
+
* (`.../extensions/vexp.vexp-vscode-<version>.../`): an editor window
|
|
64
|
+
* started it, and owns it. */
|
|
65
|
+
export function startedByEditor(exe) {
|
|
66
|
+
return !!exe && /vexp\.vexp-vscode-\d/i.test(exe);
|
|
67
|
+
}
|
|
68
|
+
/** The first line the engine printed, looking through its streams in the
|
|
69
|
+
* order given: stdout first for an answer, stderr first for an error. */
|
|
70
|
+
export function engineSaid(...streams) {
|
|
71
|
+
for (const text of streams) {
|
|
72
|
+
const line = (text ?? "").split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
|
|
73
|
+
if (line)
|
|
74
|
+
return line;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
/** Did `daemon-cmd stop` end a daemon? Its sentence is "Daemon (PID n)
|
|
79
|
+
* stopped"; "No daemon running (no PID file found)" and "... was not
|
|
80
|
+
* running" also exit 0, and neither ended the daemon the listing saw. */
|
|
81
|
+
export function daemonWasStopped(said, status) {
|
|
82
|
+
return status === 0 && /Daemon \(PID \d+\) stopped\b/.test(said);
|
|
83
|
+
}
|
|
84
|
+
/** The lines that go under a workspace's own line. */
|
|
85
|
+
export function stopNotes(result) {
|
|
86
|
+
const notes = [];
|
|
87
|
+
if (result.target.alsoServes.length > 0) {
|
|
88
|
+
notes.push(`the same daemon served ${result.target.alsoServes.join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
// Said whether or not the stop landed: either way this daemon is back, or
|
|
91
|
+
// still there, a moment later.
|
|
92
|
+
if (startedByEditor(result.target.exe)) {
|
|
93
|
+
notes.push("started by an editor window, which starts it again: close or reload that window");
|
|
94
|
+
}
|
|
95
|
+
return notes;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether to say, once, that a stop is not a good-bye: while `vexp serve`
|
|
99
|
+
* runs, a stopped workspace that still has its manifest is brought back
|
|
100
|
+
* within a minute (the note `vexp stop <workspace>` prints).
|
|
101
|
+
*/
|
|
102
|
+
export function supervisorNoteDue(results, hasManifest) {
|
|
103
|
+
return results.some((r) => r.stopped && hasManifest(r.target.workspace));
|
|
104
|
+
}
|
|
105
|
+
/** The last line, and whether every daemon listed was stopped. */
|
|
106
|
+
export function stopAllSummary(results) {
|
|
107
|
+
const total = results.length;
|
|
108
|
+
const stopped = results.filter((r) => r.stopped).length;
|
|
109
|
+
const daemons = (n) => `${n} daemon${n === 1 ? "" : "s"}`;
|
|
110
|
+
if (stopped === total)
|
|
111
|
+
return { line: `${daemons(stopped)} stopped`, ok: true };
|
|
112
|
+
return {
|
|
113
|
+
line: `${stopped} of ${daemons(total)} stopped; ${total - stopped} not stopped, for the reason given on its line`,
|
|
114
|
+
ok: false,
|
|
115
|
+
};
|
|
116
|
+
}
|