vexp-cli 2.2.4 → 2.3.1
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 +416 -165
- package/dist/cli.js +6 -2
- package/dist/doctor.js +233 -23
- package/dist/serve.js +56 -2
- package/mcp/mcp-server.cjs +29 -29
- package/package.json +6 -6
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from "fs";
|
|
|
8
8
|
import * as net from "net";
|
|
9
9
|
import { checkbox, confirm } from "@inquirer/prompts";
|
|
10
10
|
import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
|
|
11
|
-
import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName } from "./agent-config.js";
|
|
11
|
+
import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode } from "./agent-config.js";
|
|
12
12
|
import { CLI_VERSION } from "./version.js";
|
|
13
13
|
import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
|
|
14
14
|
import { checkForUpdate } from "./update-check.js";
|
|
@@ -533,8 +533,10 @@ program
|
|
|
533
533
|
.option("--agents [list]", "Filter agents to configure (comma-separated). Without value: auto-detect all")
|
|
534
534
|
.option("--dry-run", "Show what would be configured without writing files")
|
|
535
535
|
.option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
|
|
536
|
+
.option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
|
|
536
537
|
.action(async (dir, opts) => {
|
|
537
538
|
const workspaceRoot = path.resolve(dir ?? process.cwd());
|
|
539
|
+
setGuardMode(opts.guardStrict ? "strict" : "off");
|
|
538
540
|
console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
|
|
539
541
|
// Step 1: Ensure binary
|
|
540
542
|
const spinner1 = ora("Checking vexp binary...").start();
|
|
@@ -737,7 +739,9 @@ program
|
|
|
737
739
|
program
|
|
738
740
|
.command("setup-agents [dir]")
|
|
739
741
|
.description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
|
|
740
|
-
.
|
|
742
|
+
.option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
|
|
743
|
+
.action(async (dir, opts) => {
|
|
744
|
+
setGuardMode(opts.guardStrict ? "strict" : "off");
|
|
741
745
|
await runSetupAgents(dir);
|
|
742
746
|
});
|
|
743
747
|
async function runSetupAgents(dir) {
|
package/dist/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as net from "net";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { socketPathFor } from "./socket-path.js";
|
|
7
8
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
@@ -48,6 +49,32 @@ function resolveWorkspace() {
|
|
|
48
49
|
// it — so doctor probed a pipe name the daemon never binds for ~6% of Windows
|
|
49
50
|
// workspaces and declared a healthy daemon down.
|
|
50
51
|
const socketForWorkspace = socketPathFor;
|
|
52
|
+
/** One-shot daemon query over the newline-JSON socket protocol. Returns the
|
|
53
|
+
* `result` payload or null on any failure — doctor must never hang or throw. */
|
|
54
|
+
function queryDaemon(sock, tool, timeoutMs = 2500) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
|
|
57
|
+
let buf = "";
|
|
58
|
+
const t = setTimeout(() => { c.destroy(); resolve(null); }, timeoutMs);
|
|
59
|
+
c.on("connect", () => c.write(JSON.stringify({ id: 1, tool, params: {} }) + "\n"));
|
|
60
|
+
c.on("data", (d) => {
|
|
61
|
+
buf += d.toString();
|
|
62
|
+
const i = buf.indexOf("\n");
|
|
63
|
+
if (i !== -1) {
|
|
64
|
+
clearTimeout(t);
|
|
65
|
+
c.destroy();
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(buf.slice(0, i));
|
|
68
|
+
resolve(parsed?.result ?? null);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
resolve(null);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
c.on("error", () => { clearTimeout(t); resolve(null); });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
51
78
|
function reachable(sock, timeoutMs = 600) {
|
|
52
79
|
return new Promise((resolve) => {
|
|
53
80
|
const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
|
|
@@ -111,6 +138,39 @@ export async function runDoctor() {
|
|
|
111
138
|
line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
|
|
112
139
|
if (!live)
|
|
113
140
|
console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
|
|
141
|
+
// 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
|
|
142
|
+
// say "LLM enabled" while the daemon serves rule-compressed output (config
|
|
143
|
+
// race / stale daemon / non-LLM build); only the daemon knows what's active.
|
|
144
|
+
if (live) {
|
|
145
|
+
const st = await queryDaemon(sock, "index_status");
|
|
146
|
+
if (st) {
|
|
147
|
+
const comp = st.compressor === "llm"
|
|
148
|
+
? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
|
|
149
|
+
: st.compressor ?? "unknown (older daemon)";
|
|
150
|
+
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
151
|
+
if (st.llm_configured_but_inactive === true) {
|
|
152
|
+
line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
|
|
153
|
+
}
|
|
154
|
+
// 2.3 C1 — which agent sessions actually used vexp (a session with zero
|
|
155
|
+
// calls never shows up here; that absence is the diagnostic).
|
|
156
|
+
const sessions = Array.isArray(st.sessions) ? st.sessions : [];
|
|
157
|
+
if (sessions.length > 0) {
|
|
158
|
+
const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
|
|
159
|
+
line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
|
|
160
|
+
}
|
|
161
|
+
else if (Number(st.daemon_uptime_s) > 600) {
|
|
162
|
+
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
163
|
+
// the daemon was unreachable serves from its own in-process index and
|
|
164
|
+
// never attaches to the daemon later — its calls are real but
|
|
165
|
+
// invisible to these counters. Don't tell that user "the agent is not
|
|
166
|
+
// using vexp"; tell them how to converge on the daemon.
|
|
167
|
+
line(WARN, `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
line(OK, `no session calls yet (daemon just started)`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
114
174
|
// 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
|
|
115
175
|
console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
|
|
116
176
|
const regPath = path.join(home, ".vexp", "daemons.json");
|
|
@@ -166,32 +226,44 @@ export async function runDoctor() {
|
|
|
166
226
|
line(OK, `${name}: valid, ~${days}d remaining`);
|
|
167
227
|
}
|
|
168
228
|
}
|
|
169
|
-
// 4) Codex MCP transport stanza.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
229
|
+
// 4) Codex MCP transport stanza — global AND project-level. Codex supports
|
|
230
|
+
// per-project `.codex/config.toml`; checking only the global file made
|
|
231
|
+
// doctor report "[OK] no stanza" to users whose (working) config lives in
|
|
232
|
+
// the workspace.
|
|
233
|
+
console.log(chalk.bold("\nCodex (config.toml)"));
|
|
234
|
+
const codexConfigs = [
|
|
235
|
+
{ label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
|
|
236
|
+
{ label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
|
|
237
|
+
];
|
|
238
|
+
let codexStanzaSeen = false;
|
|
239
|
+
for (const { label, file } of codexConfigs) {
|
|
240
|
+
if (!fs.existsSync(file)) {
|
|
241
|
+
line(OK, `${label}: absent`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const toml = fs.readFileSync(file, "utf-8");
|
|
177
245
|
const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
|
|
178
246
|
const section = m ? m[0] : "";
|
|
179
|
-
if (!section)
|
|
180
|
-
line(OK,
|
|
181
|
-
|
|
182
|
-
const hasUrl = /^\s*url\s*=/m.test(section);
|
|
183
|
-
const hasCmd = /^\s*command\s*=/m.test(section);
|
|
184
|
-
if (hasUrl && hasCmd)
|
|
185
|
-
line(BAD, "stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.");
|
|
186
|
-
else if (hasUrl)
|
|
187
|
-
line(OK, "transport: http (url)");
|
|
188
|
-
else if (hasCmd) {
|
|
189
|
-
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
190
|
-
line(OK, `transport: stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
|
|
191
|
-
}
|
|
192
|
-
else
|
|
193
|
-
line(WARN, "stanza present but neither url nor command found");
|
|
247
|
+
if (!section) {
|
|
248
|
+
line(OK, `${label}: no [mcp_servers.vexp] stanza`);
|
|
249
|
+
continue;
|
|
194
250
|
}
|
|
251
|
+
codexStanzaSeen = true;
|
|
252
|
+
const hasUrl = /^\s*url\s*=/m.test(section);
|
|
253
|
+
const hasCmd = /^\s*command\s*=/m.test(section);
|
|
254
|
+
if (hasUrl && hasCmd)
|
|
255
|
+
line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
|
|
256
|
+
else if (hasUrl)
|
|
257
|
+
line(OK, `${label}: transport http (url)`);
|
|
258
|
+
else if (hasCmd) {
|
|
259
|
+
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
260
|
+
line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
line(WARN, `${label}: stanza present but neither url nor command found`);
|
|
264
|
+
}
|
|
265
|
+
if (!codexStanzaSeen) {
|
|
266
|
+
console.log(chalk.dim(" → no vexp stanza in either file (Codex not configured for vexp)"));
|
|
195
267
|
}
|
|
196
268
|
// 5) Claude Code entry (should be UNPINNED after the multi-session fix).
|
|
197
269
|
console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
|
|
@@ -209,6 +281,144 @@ export async function runDoctor() {
|
|
|
209
281
|
catch {
|
|
210
282
|
line(OK, "no ~/.claude.json");
|
|
211
283
|
}
|
|
284
|
+
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
285
|
+
// just check presence. A shell-form command that word-splits on a project
|
|
286
|
+
// path containing a space fails non-blocking on every call: the guard never
|
|
287
|
+
// denies anything while the config "looks correct" and presence-only checks
|
|
288
|
+
// report healthy (Nathan, 2026-07).
|
|
289
|
+
console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
|
|
290
|
+
{
|
|
291
|
+
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
292
|
+
let guardHooks = [];
|
|
293
|
+
let settingsReadable = false;
|
|
294
|
+
try {
|
|
295
|
+
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
296
|
+
settingsReadable = true;
|
|
297
|
+
const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
298
|
+
for (const m of pre) {
|
|
299
|
+
const hks = Array.isArray(m?.hooks) ? m.hooks : [];
|
|
300
|
+
for (const h of hks) {
|
|
301
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
302
|
+
guardHooks.push(h);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch { /* absent or unparseable */ }
|
|
307
|
+
if (!settingsReadable) {
|
|
308
|
+
line(OK, "no .claude/settings.json (guard not installed)");
|
|
309
|
+
}
|
|
310
|
+
else if (guardHooks.length === 0) {
|
|
311
|
+
line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
|
|
312
|
+
}
|
|
313
|
+
else if (process.platform === "win32") {
|
|
314
|
+
line(OK, `guard configured (${guardHooks.length} entry) — live execution check skipped on Windows`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
for (const h of guardHooks) {
|
|
318
|
+
const cmd = h.command;
|
|
319
|
+
const execForm = Array.isArray(h.args);
|
|
320
|
+
const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
|
|
321
|
+
if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
|
|
322
|
+
line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command ('args' missing) — unquoted $CLAUDE_PROJECT_DIR word-splits on paths with spaces${ws.root.includes(" ") ? ` and THIS project path has one: the guard never runs` : ""}. Re-run 'vexp setup --guard-strict' to rewrite in exec form.`);
|
|
323
|
+
}
|
|
324
|
+
if (timeoutS > 600) {
|
|
325
|
+
line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
|
|
326
|
+
}
|
|
327
|
+
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
328
|
+
// the placeholder substituted by the host; shell form = sh -c with
|
|
329
|
+
// CLAUDE_PROJECT_DIR in the environment.
|
|
330
|
+
const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
|
|
331
|
+
const r = execForm
|
|
332
|
+
? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
|
|
333
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
334
|
+
timeout: 5000,
|
|
335
|
+
encoding: "utf-8",
|
|
336
|
+
})
|
|
337
|
+
: spawnSync("sh", ["-c", cmd], {
|
|
338
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
339
|
+
timeout: 5000,
|
|
340
|
+
encoding: "utf-8",
|
|
341
|
+
});
|
|
342
|
+
if (r.error) {
|
|
343
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${substituted}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
344
|
+
}
|
|
345
|
+
else if (r.status !== 0) {
|
|
346
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Claude Code treats this as a non-blocking failure, so searches proceed unguarded.`);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const decision = /"permissionDecision"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
350
|
+
if (decision)
|
|
351
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
352
|
+
else
|
|
353
|
+
line(WARN, `guard hook ran (exit 0) but produced no permissionDecision output — check ${path.join(".claude", "hooks", "vexp-guard.sh")}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
359
|
+
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
360
|
+
// cannot spawn silently enforces nothing there as well. The guard's stdin
|
|
361
|
+
// protocol: JSON {tool_name, tool_input, workspace_roots, cwd}; a healthy
|
|
362
|
+
// run answers {"permission":"allow"} with exit 0 OR a deny verdict with
|
|
363
|
+
// exit 2 — BOTH mean "the hook works", anything else is a failure.
|
|
364
|
+
console.log(chalk.bold("\nCursor guard hook (.cursor/hooks.json)"));
|
|
365
|
+
{
|
|
366
|
+
const cursorCfgPath = path.join(ws.root, ".cursor", "hooks.json");
|
|
367
|
+
let cursorCmds = [];
|
|
368
|
+
let cursorReadable = false;
|
|
369
|
+
try {
|
|
370
|
+
const cfg = JSON.parse(fs.readFileSync(cursorCfgPath, "utf-8"));
|
|
371
|
+
cursorReadable = true;
|
|
372
|
+
const pre = Array.isArray(cfg?.hooks?.preToolUse) ? cfg.hooks.preToolUse : [];
|
|
373
|
+
for (const h of pre) {
|
|
374
|
+
if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
|
|
375
|
+
cursorCmds.push(h.command);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch { /* absent or unparseable */ }
|
|
379
|
+
if (!cursorReadable) {
|
|
380
|
+
line(OK, "no .cursor/hooks.json (guard not installed)");
|
|
381
|
+
}
|
|
382
|
+
else if (cursorCmds.length === 0) {
|
|
383
|
+
line(OK, "no vexp guard configured (enable with 'vexp setup --guard-strict')");
|
|
384
|
+
}
|
|
385
|
+
else if (process.platform === "win32") {
|
|
386
|
+
line(OK, `guard configured (${cursorCmds.length} entry) — live execution check skipped on Windows`);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
// A benign Grep probe: with a healthy daemon the guard denies (exit 2),
|
|
390
|
+
// without one it allows (exit 0) — either proves the hook executes.
|
|
391
|
+
const probe = JSON.stringify({
|
|
392
|
+
tool_name: "Grep",
|
|
393
|
+
tool_input: {},
|
|
394
|
+
workspace_roots: [ws.root],
|
|
395
|
+
cwd: ws.root,
|
|
396
|
+
});
|
|
397
|
+
for (const cmd of cursorCmds) {
|
|
398
|
+
// Cursor runs project hooks from the project root; emulate that.
|
|
399
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
400
|
+
cwd: ws.root,
|
|
401
|
+
input: probe,
|
|
402
|
+
timeout: 5000,
|
|
403
|
+
encoding: "utf-8",
|
|
404
|
+
});
|
|
405
|
+
if (r.error) {
|
|
406
|
+
line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${cmd}' — Cursor hooks fail open, so the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const decision = /"permission"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
|
|
410
|
+
if ((r.status === 0 || r.status === 2) && decision) {
|
|
411
|
+
line(OK, `guard hook runs (live decision here: ${decision})`);
|
|
412
|
+
}
|
|
413
|
+
else if (r.status !== 0 && r.status !== 2) {
|
|
414
|
+
line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Cursor treats hook failures as allow, so searches proceed unguarded.`);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
line(WARN, `guard hook ran (exit ${r.status}) but produced no permission verdict — check ${path.join(".cursor", "hooks", "vexp-guard.js")}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
212
422
|
// 6) HTTP MCP supervisor.
|
|
213
423
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
214
424
|
try {
|
package/dist/serve.js
CHANGED
|
@@ -89,17 +89,71 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
|
|
|
89
89
|
return false;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/** True when `sock` is the canonical socket for `ws` itself (unix path or the
|
|
93
|
+
* win32 named pipe derived from `ws`). A registry row that FAILS this is a
|
|
94
|
+
* ROUTING entry: a connected secondary repo mapped to its parent workspace's
|
|
95
|
+
* socket so lookups route correctly. */
|
|
96
|
+
export function isOwnerEntry(ws, sock) {
|
|
97
|
+
if (process.platform === "win32") {
|
|
98
|
+
// Named pipes carry no path; treat the row as owner unless another row
|
|
99
|
+
// with the same pipe proves otherwise (handled by the grouping below).
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return path.resolve(sock) === path.resolve(path.join(ws, ".vexp", "daemon.sock"));
|
|
103
|
+
}
|
|
104
|
+
/** One (workspace, socket) spawn per SOCKET, always the socket's OWNER root.
|
|
105
|
+
* Field incident (2026-07-23): the registry legitimately maps a connected
|
|
106
|
+
* repo to its PARENT workspace's socket for routing; resurrecting that row
|
|
107
|
+
* verbatim spawned `daemon --workspace <child> --socket <parent sock>` — a
|
|
108
|
+
* cross-wired daemon serving the child's index on the parent's socket, which
|
|
109
|
+
* then blocked every legitimate restart of the parent (it held the bind) and
|
|
110
|
+
* never matched the parent's config. Routing rows must never be spawned. */
|
|
111
|
+
export function resurrectionPlan(reg) {
|
|
112
|
+
const bySock = new Map();
|
|
113
|
+
for (const [ws, sock] of Object.entries(reg)) {
|
|
114
|
+
const list = bySock.get(sock) ?? [];
|
|
115
|
+
list.push(ws);
|
|
116
|
+
bySock.set(sock, list);
|
|
117
|
+
}
|
|
118
|
+
const plan = [];
|
|
119
|
+
for (const [sock, roots] of bySock) {
|
|
120
|
+
const owner = roots.find((ws) => isOwnerEntry(ws, sock));
|
|
121
|
+
if (owner) {
|
|
122
|
+
plan.push({ ws: owner, sock });
|
|
123
|
+
}
|
|
124
|
+
else if (roots.length === 1 && !sock.endsWith(path.join(".vexp", "daemon.sock"))) {
|
|
125
|
+
// Long-path /tmp-fallback socket (lives OUTSIDE any repo): a single
|
|
126
|
+
// registrant is unambiguous, keep prior behavior. A canonical
|
|
127
|
+
// in-repo socket that is NOT under the registrant is a routing row
|
|
128
|
+
// whose owner vanished from the registry — never cross-spawn it.
|
|
129
|
+
plan.push({ ws: roots[0], sock });
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
appendLog(`registry: no owner for socket ${sock} (roots: ${roots.join(", ")}) — skipping resurrect`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return plan;
|
|
136
|
+
}
|
|
92
137
|
async function resurrectAll() {
|
|
93
138
|
const reg = readRegistry();
|
|
94
139
|
const live = {};
|
|
140
|
+
// Prune rows whose workspace vanished, but keep routing rows alive in the
|
|
141
|
+
// registry — they are data, not spawn instructions.
|
|
142
|
+
const pruned = {};
|
|
95
143
|
for (const [ws, sock] of Object.entries(reg)) {
|
|
96
144
|
const manifest = path.join(ws, ".vexp", "manifest.json");
|
|
97
145
|
if (!fs.existsSync(manifest)) {
|
|
98
146
|
appendLog(`registry prune: ${ws} (no manifest)`);
|
|
99
147
|
continue;
|
|
100
148
|
}
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
pruned[ws] = sock;
|
|
150
|
+
}
|
|
151
|
+
const resurrected = new Map();
|
|
152
|
+
for (const { ws, sock } of resurrectionPlan(pruned)) {
|
|
153
|
+
resurrected.set(sock, await resurrectDaemon(ws, sock));
|
|
154
|
+
}
|
|
155
|
+
for (const [ws, sock] of Object.entries(pruned)) {
|
|
156
|
+
if (resurrected.get(sock))
|
|
103
157
|
live[ws] = sock;
|
|
104
158
|
}
|
|
105
159
|
writeRegistry(live);
|