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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as net from "net";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { isOlderVersion } from "./mcp-supervisor.js";
|
|
5
|
+
import { vexpHome } from "./state-home.js";
|
|
6
|
+
/** How long a workspace is left alone after a replacement was tried for it. */
|
|
7
|
+
export const UPGRADE_COOLDOWN_MS = 10 * 60_000;
|
|
8
|
+
/** How long the daemon is given to answer `index_status`. */
|
|
9
|
+
export const UPGRADE_STATUS_TIMEOUT_MS = 1_500;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a launcher replaces the daemon that answered. The rules, in order:
|
|
12
|
+
*
|
|
13
|
+
* 1. Switched off: VEXP_NO_DAEMON_UPGRADE=1, or VEXP_NO_AUTOSTART=1, which
|
|
14
|
+
* already means "start nothing on my behalf".
|
|
15
|
+
* 2. A version that is missing, or is not a version: nothing to compare.
|
|
16
|
+
* 3. ORDERED, not merely different. An older daemon is replaced and a newer
|
|
17
|
+
* one is left alone: two installs at two versions never converge under
|
|
18
|
+
* "different", each replaces the other's daemon at its next use (the
|
|
19
|
+
* extension learnt it with three editors on one machine, 3.1.1).
|
|
20
|
+
* 4. A daemon running the engine inside an editor extension's folder
|
|
21
|
+
* belongs to an editor window, which starts the same build again the
|
|
22
|
+
* moment this one stops. It changes when the editor updates.
|
|
23
|
+
* 5. A daemon still building or syncing its index is left to finish, since
|
|
24
|
+
* a restart begins the sync again; it is replaced at a later use. Only
|
|
25
|
+
* "healthy" is a settled state: the engine also answers "empty" and
|
|
26
|
+
* "degraded" before its startup sync is done.
|
|
27
|
+
* 6. The seat belt: one attempt per workspace in ten minutes, whatever
|
|
28
|
+
* kept the last one from taking. A launcher never restarts a daemon on
|
|
29
|
+
* every invocation.
|
|
30
|
+
*/
|
|
31
|
+
export function daemonUpgradeDecision(o) {
|
|
32
|
+
if (o.disabled)
|
|
33
|
+
return { replace: false, why: "switched off" };
|
|
34
|
+
// isOlderVersion counts what it cannot parse as older: right for a pid
|
|
35
|
+
// record it must take over, wrong for a process it would restart.
|
|
36
|
+
const version = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
37
|
+
if (!o.running || !o.engine || !version.test(o.running) || !version.test(o.engine)) {
|
|
38
|
+
return { replace: false, why: "version unknown" };
|
|
39
|
+
}
|
|
40
|
+
if (!isOlderVersion(o.running, o.engine))
|
|
41
|
+
return { replace: false, why: "daemon is not older" };
|
|
42
|
+
if (o.exe && /vexp\.vexp-vscode-\d/i.test(o.exe))
|
|
43
|
+
return { replace: false, why: "started by an editor window" };
|
|
44
|
+
if (o.status !== "healthy")
|
|
45
|
+
return { replace: false, why: "index not settled" };
|
|
46
|
+
// Either side of now: a record from a clock that was ahead holds as well.
|
|
47
|
+
if (o.lastAttemptMs !== undefined && Math.abs(o.nowMs - o.lastAttemptMs) < UPGRADE_COOLDOWN_MS) {
|
|
48
|
+
return { replace: false, why: "tried in the last ten minutes" };
|
|
49
|
+
}
|
|
50
|
+
return { replace: true };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Where the attempts are remembered: vexp's own state directory, one small
|
|
54
|
+
* file for the machine, shared by the CLI and the MCP server so that they
|
|
55
|
+
* keep ONE cool-down between them. Not a file in `<workspace>/.vexp/`:
|
|
56
|
+
* everything there has to be named in the .gitignore the engine generates,
|
|
57
|
+
* and a file this side added on its own would show up in `git status`.
|
|
58
|
+
*/
|
|
59
|
+
export function upgradeAttemptsPath() {
|
|
60
|
+
return path.join(vexpHome(), ".vexp", "daemon-upgrade.json");
|
|
61
|
+
}
|
|
62
|
+
/** The key a workspace's attempt is kept under: its resolved path, so the
|
|
63
|
+
* two launchers agree however each was told the folder; lowercased on
|
|
64
|
+
* Windows, as the daemon registry is. */
|
|
65
|
+
export function upgradeAttemptKey(workspace) {
|
|
66
|
+
let resolved = path.resolve(workspace);
|
|
67
|
+
try {
|
|
68
|
+
resolved = fs.realpathSync.native(resolved);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* not there: the spelling given */
|
|
72
|
+
}
|
|
73
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
74
|
+
}
|
|
75
|
+
/** Workspace key -> epoch ms of the last attempt. A file that is missing,
|
|
76
|
+
* corrupt or of another shape reads as no attempts. */
|
|
77
|
+
export function readUpgradeAttempts(file = upgradeAttemptsPath()) {
|
|
78
|
+
const attempts = {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
81
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
82
|
+
return attempts;
|
|
83
|
+
for (const [key, at] of Object.entries(raw)) {
|
|
84
|
+
if (typeof at === "number" && Number.isFinite(at))
|
|
85
|
+
attempts[key] = at;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* no file yet, or not JSON */
|
|
90
|
+
}
|
|
91
|
+
return attempts;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Remember that a replacement is being tried for `workspace`. Entries past
|
|
95
|
+
* their cool-down are dropped on the way, so the file stays as small as the
|
|
96
|
+
* last ten minutes. Temp file and rename: a reader never sees half a file.
|
|
97
|
+
* Two processes writing at once can lose one entry, which costs at most one
|
|
98
|
+
* extra attempt. Returns false when nothing could be written: the caller
|
|
99
|
+
* then does not restart, because without the record nothing would stop it
|
|
100
|
+
* from restarting again at the next invocation.
|
|
101
|
+
*/
|
|
102
|
+
export function recordUpgradeAttempt(workspace, nowMs, file = upgradeAttemptsPath()) {
|
|
103
|
+
try {
|
|
104
|
+
const kept = {};
|
|
105
|
+
for (const [key, at] of Object.entries(readUpgradeAttempts(file))) {
|
|
106
|
+
if (Math.abs(nowMs - at) < UPGRADE_COOLDOWN_MS)
|
|
107
|
+
kept[key] = at;
|
|
108
|
+
}
|
|
109
|
+
kept[upgradeAttemptKey(workspace)] = nowMs;
|
|
110
|
+
const content = JSON.stringify(kept, null, 2) + "\n";
|
|
111
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
112
|
+
const tmp = `${file}.tmp-${process.pid}-${nowMs}`;
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
115
|
+
fs.renameSync(tmp, file);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A Windows rename refused because another process holds the target.
|
|
119
|
+
try {
|
|
120
|
+
fs.unlinkSync(tmp);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* never written */
|
|
124
|
+
}
|
|
125
|
+
fs.writeFileSync(file, content, "utf-8");
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* `index_status` from the daemon listening on `socket`, or null when it does
|
|
135
|
+
* not answer in time. One JSON line each way.
|
|
136
|
+
*
|
|
137
|
+
* No session id, like the extension's version probe and doctor's: a request
|
|
138
|
+
* that carries one opens a row in the daemon's session map, where the
|
|
139
|
+
* sidebar's probe once read as an agent with 360 tool calls an hour (field
|
|
140
|
+
* report, 2026-09-28). A launcher asks before every command.
|
|
141
|
+
*/
|
|
142
|
+
export function queryIndexStatus(socket, timeoutMs) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let sock;
|
|
145
|
+
try {
|
|
146
|
+
sock = net.createConnection({ path: socket });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
resolve(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let buf = "";
|
|
153
|
+
let settled = false;
|
|
154
|
+
const finish = (answer) => {
|
|
155
|
+
if (settled)
|
|
156
|
+
return;
|
|
157
|
+
settled = true;
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
sock.destroy();
|
|
160
|
+
resolve(answer);
|
|
161
|
+
};
|
|
162
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
163
|
+
sock.on("connect", () => {
|
|
164
|
+
sock.write(JSON.stringify({ id: 1, tool: "index_status", params: {} }) + "\n");
|
|
165
|
+
});
|
|
166
|
+
sock.on("data", (chunk) => {
|
|
167
|
+
buf += chunk.toString();
|
|
168
|
+
const end = buf.indexOf("\n");
|
|
169
|
+
if (end === -1)
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
const result = JSON.parse(buf.slice(0, end))?.result;
|
|
173
|
+
finish(result && typeof result === "object" && !Array.isArray(result) ? result : null);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
finish(null);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
sock.on("error", () => finish(null));
|
|
180
|
+
sock.on("close", () => finish(null));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** The version in what `vexp-core --version` prints (`vexp-core 3.3.3`): its
|
|
184
|
+
* last word, as the extension and doctor read it. */
|
|
185
|
+
export function parseEngineVersion(stdout) {
|
|
186
|
+
const last = stdout.trim().split(/\s+/).pop();
|
|
187
|
+
return last ? last : undefined;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The folder `daemon-cmd` works on when run in `dir`: the nearest one with a
|
|
191
|
+
* `.git` at or above it, else `dir` itself (find_repo_root in vexp-core).
|
|
192
|
+
*/
|
|
193
|
+
export function daemonCmdRoot(dir) {
|
|
194
|
+
let start = path.resolve(dir);
|
|
195
|
+
try {
|
|
196
|
+
start = fs.realpathSync.native(start);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* not there: the spelling given */
|
|
200
|
+
}
|
|
201
|
+
let current = start;
|
|
202
|
+
for (;;) {
|
|
203
|
+
if (fs.existsSync(path.join(current, ".git")))
|
|
204
|
+
return current;
|
|
205
|
+
const parent = path.dirname(current);
|
|
206
|
+
if (parent === current)
|
|
207
|
+
return start;
|
|
208
|
+
current = parent;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Why `daemon-cmd restart` run in `dir` would not put a new daemon in the
|
|
213
|
+
* old one's place, or null when it would. A replacement must never end as a
|
|
214
|
+
* removal, or as a second daemon somewhere else:
|
|
215
|
+
*
|
|
216
|
+
* - the stop half reads `<dir>/.vexp/daemon.pid`; with none there it stops
|
|
217
|
+
* nothing, and the start half then races the daemon that is still up;
|
|
218
|
+
* - `daemon-cmd` works on the git root at or above its cwd, so for a
|
|
219
|
+
* workspace below one it would start a daemon on the folder above;
|
|
220
|
+
* - the engine refuses to start on the home directory, so a daemon an older
|
|
221
|
+
* build left there would be stopped and not come back.
|
|
222
|
+
*/
|
|
223
|
+
export function whyNotRestartable(dir) {
|
|
224
|
+
let isDir = false;
|
|
225
|
+
try {
|
|
226
|
+
isDir = fs.statSync(dir).isDirectory();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* gone */
|
|
230
|
+
}
|
|
231
|
+
if (!isDir)
|
|
232
|
+
return "workspace folder not found";
|
|
233
|
+
if (!fs.existsSync(path.join(dir, ".vexp", "daemon.pid")))
|
|
234
|
+
return "no pid file in the workspace";
|
|
235
|
+
const key = upgradeAttemptKey(dir);
|
|
236
|
+
if (upgradeAttemptKey(daemonCmdRoot(dir)) !== key)
|
|
237
|
+
return "daemon-cmd works on the git root above";
|
|
238
|
+
if (process.env.VEXP_ALLOW_HOME_WORKSPACE !== "1" && key === upgradeAttemptKey(vexpHome())) {
|
|
239
|
+
return "the home directory is not a workspace";
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Ask the daemon on `socket` what it is and, when the rules above say so,
|
|
245
|
+
* restart it with this launcher's engine. Never throws; a daemon that does
|
|
246
|
+
* not answer is nobody's to replace.
|
|
247
|
+
*
|
|
248
|
+
* The daemon's own `workspace_root` names the folder to restart in, when it
|
|
249
|
+
* gives one: a session in a subfolder or in a connected repo is served by
|
|
250
|
+
* the daemon of another folder, and that is the one to replace.
|
|
251
|
+
*
|
|
252
|
+
* The attempt is recorded BEFORE the restart, so that a restart which hangs,
|
|
253
|
+
* fails or does not change the version is still the one attempt of its ten
|
|
254
|
+
* minutes.
|
|
255
|
+
*/
|
|
256
|
+
export async function replaceOlderDaemon(o) {
|
|
257
|
+
const disabled = process.env.VEXP_NO_DAEMON_UPGRADE === "1" || process.env.VEXP_NO_AUTOSTART === "1";
|
|
258
|
+
if (disabled)
|
|
259
|
+
return { replaced: false, why: "switched off", up: false };
|
|
260
|
+
const text = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
261
|
+
const left = (why) => ({ replaced: false, why, up: true });
|
|
262
|
+
try {
|
|
263
|
+
const status = await queryIndexStatus(o.socket, UPGRADE_STATUS_TIMEOUT_MS);
|
|
264
|
+
if (!status)
|
|
265
|
+
return { replaced: false, why: "no answer", up: false };
|
|
266
|
+
const running = text(status.daemon_version);
|
|
267
|
+
if (!running)
|
|
268
|
+
return left("version unknown");
|
|
269
|
+
if (o.launcherVersion && !isOlderVersion(running, o.launcherVersion))
|
|
270
|
+
return left("daemon is not older");
|
|
271
|
+
const engine = await o.engineVersion();
|
|
272
|
+
const dir = text(status.workspace_root) ?? o.workspace;
|
|
273
|
+
const file = o.attemptsFile ?? upgradeAttemptsPath();
|
|
274
|
+
const nowMs = o.nowMs ?? Date.now();
|
|
275
|
+
const decision = daemonUpgradeDecision({
|
|
276
|
+
running,
|
|
277
|
+
engine,
|
|
278
|
+
exe: text(status.daemon_exe),
|
|
279
|
+
status: text(status.status),
|
|
280
|
+
lastAttemptMs: readUpgradeAttempts(file)[upgradeAttemptKey(dir)],
|
|
281
|
+
nowMs,
|
|
282
|
+
disabled,
|
|
283
|
+
});
|
|
284
|
+
if (!decision.replace)
|
|
285
|
+
return left(decision.why);
|
|
286
|
+
if (!engine)
|
|
287
|
+
return left("version unknown");
|
|
288
|
+
const unfit = whyNotRestartable(dir);
|
|
289
|
+
if (unfit)
|
|
290
|
+
return left(unfit);
|
|
291
|
+
if (!recordUpgradeAttempt(dir, nowMs, file))
|
|
292
|
+
return left("attempt could not be recorded");
|
|
293
|
+
try {
|
|
294
|
+
await o.restart(dir);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return { replaced: false, why: "restart failed", up: false };
|
|
298
|
+
}
|
|
299
|
+
if (o.confirm) {
|
|
300
|
+
// The restart ran and did not fail. One answer makes it no
|
|
301
|
+
// replacement: the older version again, from a daemon the stop did
|
|
302
|
+
// not end or that its owner started anew. A daemon too busy starting
|
|
303
|
+
// to answer is the new one.
|
|
304
|
+
const after = text((await queryIndexStatus(o.socket, UPGRADE_STATUS_TIMEOUT_MS))?.daemon_version);
|
|
305
|
+
if (after && isOlderVersion(after, engine))
|
|
306
|
+
return left("restart did not take");
|
|
307
|
+
}
|
|
308
|
+
return { replaced: true, running, engine };
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { replaced: false, why: "check failed", up: false };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// CLI only: which commands may replace a daemon.
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
/**
|
|
318
|
+
* Whether the bootstrap before `vexp <name>` may replace an older daemon.
|
|
319
|
+
*
|
|
320
|
+
* Most commands never reach the bootstrap at all (AUTOSTART_SKIP in cli.ts:
|
|
321
|
+
* `stop`, `forget`, `daemons`, `doctor`, `daemon-cmd`, `setup` and the
|
|
322
|
+
* others that manage a daemon themselves or need none). Of those that do,
|
|
323
|
+
* three kinds still must not restart one:
|
|
324
|
+
*
|
|
325
|
+
* - `mcp` is a protocol server an agent starts with every session, often
|
|
326
|
+
* several at once: its first seconds belong to the handshake;
|
|
327
|
+
* - `daemon` IS a daemon start;
|
|
328
|
+
* - `index --finalize`, `--merge-recover` and `--checkout-sync` are run by
|
|
329
|
+
* the git hooks, inside someone's commit, merge or checkout.
|
|
330
|
+
*/
|
|
331
|
+
export function commandMayReplaceDaemon(name, opts = {}) {
|
|
332
|
+
if (name === "mcp" || name === "daemon")
|
|
333
|
+
return false;
|
|
334
|
+
if (name === "index" && (opts.finalize || opts.mergeRecover || opts.checkoutSync))
|
|
335
|
+
return false;
|
|
336
|
+
return true;
|
|
337
|
+
}
|