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.
@@ -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
+ }