vexp-cli 3.2.2 → 3.2.5
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 +20 -1
- package/dist/cli.js +67 -1
- package/dist/doctor.js +23 -13
- package/dist/forget.js +122 -0
- package/dist/serve.js +23 -9
- package/dist/socket-path.js +25 -1
- package/dist/state-home.js +23 -0
- package/mcp/mcp-server.cjs +44 -44
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as path from "path";
|
|
|
11
11
|
import * as os from "os";
|
|
12
12
|
import { spawnSync } from "child_process";
|
|
13
13
|
import * as crypto from "crypto";
|
|
14
|
+
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
14
15
|
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook } from "./hook-template.js";
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Constants
|
|
@@ -39,7 +40,9 @@ const CANONICAL_VEXP_TOML_SECTION_RE = /\n?\[mcp_servers\.vexp(?:\.[a-z_]+)?\][\
|
|
|
39
40
|
* MUST stay in lockstep with that function.
|
|
40
41
|
*/
|
|
41
42
|
function workspaceHash(workspaceRoot) {
|
|
42
|
-
|
|
43
|
+
// The route hash must name the row the daemon registers: its canonical
|
|
44
|
+
// root, which on a mapped drive is the share, not the drive letter.
|
|
45
|
+
const input = canonicalWorkspaceRoot(workspaceRoot).toLowerCase();
|
|
43
46
|
let hash = BigInt("0xcbf29ce484222325");
|
|
44
47
|
const prime = BigInt("0x100000001b3");
|
|
45
48
|
const mask = BigInt("0xffffffffffffffff");
|
|
@@ -1983,6 +1986,22 @@ function extractCodexVexpSection(content) {
|
|
|
1983
1986
|
const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
|
|
1984
1987
|
return m ? m.join("\n") : "";
|
|
1985
1988
|
}
|
|
1989
|
+
/** True when ~/.codex/config.toml carries a hand-written [mcp_servers.vexp]
|
|
1990
|
+
* section — one `vexp use` and `vexp setup` will never rewrite, so a caller
|
|
1991
|
+
* that promised to repoint Codex can say it did not. */
|
|
1992
|
+
export function codexGlobalSectionIsUserManaged() {
|
|
1993
|
+
const home = os.homedir();
|
|
1994
|
+
if (!home)
|
|
1995
|
+
return false;
|
|
1996
|
+
let content = "";
|
|
1997
|
+
try {
|
|
1998
|
+
content = fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf-8");
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
return false;
|
|
2002
|
+
}
|
|
2003
|
+
return isUserManagedCodexSection(extractCodexVexpSection(content));
|
|
2004
|
+
}
|
|
1986
2005
|
/** True when the existing [mcp_servers.vexp] section was written by the user
|
|
1987
2006
|
* (not by vexp) and must NOT be overwritten. */
|
|
1988
2007
|
function isUserManagedCodexSection(section) {
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,8 @@ import { isWsl, windsurfWslBridgeNote } from "./agent-config.js";
|
|
|
18
18
|
import { runServe } from "./serve.js";
|
|
19
19
|
import { runDoctor } from "./doctor.js";
|
|
20
20
|
import { socketPathFor } from "./socket-path.js";
|
|
21
|
+
import { isStateHome } from "./state-home.js";
|
|
22
|
+
import { forgetRegistryRows, removeIndexArtifacts } from "./forget.js";
|
|
21
23
|
import { mutableOutput, askSecretOn } from "./secret-prompt.js";
|
|
22
24
|
import { isTraceEnabled } from "./trace.js";
|
|
23
25
|
import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
|
|
@@ -48,6 +50,9 @@ const AUTOSTART_SKIP = new Set([
|
|
|
48
50
|
// followed by `daemons` bring the stopped daemon straight back
|
|
49
51
|
// (3.1 e2e, 2026-08).
|
|
50
52
|
"daemons", "stop",
|
|
53
|
+
// `vexp forget` exists to make a workspace stay down; booting one first
|
|
54
|
+
// would be the joke `stop` used to play.
|
|
55
|
+
"forget", "unregister",
|
|
51
56
|
]);
|
|
52
57
|
program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
53
58
|
// One-shot config migration for the multi-session fix — de-pins a legacy
|
|
@@ -545,10 +550,62 @@ program
|
|
|
545
550
|
console.error(`No such directory: ${target}`);
|
|
546
551
|
process.exit(1);
|
|
547
552
|
}
|
|
553
|
+
// A stop is not a good-bye: while `vexp serve` runs, every registered
|
|
554
|
+
// workspace that still has a manifest is brought back within a minute,
|
|
555
|
+
// and a user who wanted it gone read that as vexp ignoring him (field
|
|
556
|
+
// report, 2026-09-19). Say which command means gone. Printed before the
|
|
557
|
+
// stop because runBinary exits the process with the child.
|
|
558
|
+
if (fs.existsSync(path.join(target, ".vexp", "manifest.json"))) {
|
|
559
|
+
console.log(chalk.dim(` (a login supervisor restarts registered workspaces within a minute — to unregister this one for good: vexp forget "${target}")`));
|
|
560
|
+
}
|
|
548
561
|
// daemon-cmd stop resolves the daemon from its cwd, so run it from the
|
|
549
562
|
// target workspace — this is what makes `vexp stop <workspace>` global.
|
|
550
563
|
runBinary(binaryPath, ["daemon-cmd", "stop"], { cwd: target });
|
|
551
564
|
});
|
|
565
|
+
program
|
|
566
|
+
.command("forget [workspace]")
|
|
567
|
+
.alias("unregister")
|
|
568
|
+
.description("Unregister a workspace for good: stop its daemon, drop it from ~/.vexp/daemons.json and delete its index files, so no launcher brings it back (default: current directory). Safe on the home directory: only index files go — config, licence and registry stay")
|
|
569
|
+
.option("--purge", "Also delete the whole .vexp/ directory, config included (refused for the home directory)")
|
|
570
|
+
.action(async (workspace, opts) => {
|
|
571
|
+
const target = path.resolve(workspace ?? process.cwd());
|
|
572
|
+
if (!fs.existsSync(target)) {
|
|
573
|
+
console.error(`No such directory: ${target}`);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
const home = isStateHome(target);
|
|
577
|
+
if (opts.purge && home) {
|
|
578
|
+
console.error(chalk.red(`${target} is your home directory: .vexp there is vexp's state dir (config, licence, daemon registry). --purge is refused; without it only the index files go.`));
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
const binaryPath = ensureBinary();
|
|
582
|
+
// 1. The daemon. `daemon-cmd stop` resolves it from its cwd.
|
|
583
|
+
const stop = spawnSync(binaryPath, ["daemon-cmd", "stop"], {
|
|
584
|
+
cwd: target,
|
|
585
|
+
encoding: "utf-8",
|
|
586
|
+
env: binaryEnv(binaryPath),
|
|
587
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
588
|
+
});
|
|
589
|
+
const stopSaid = `${stop.stdout ?? ""}${stop.stderr ?? ""}`.trim().split(/\r?\n/)[0] ?? "";
|
|
590
|
+
console.log(` daemon: ${stopSaid || (stop.error ? stop.error.message : "stopped")}`);
|
|
591
|
+
// 2. The registry — this row and any connected repo routed to its socket.
|
|
592
|
+
const dropped = forgetRegistryRows(target, socketPathFor(target));
|
|
593
|
+
console.log(` registry: ${dropped.length ? `dropped ${dropped.join(", ")}` : "no row for this workspace"}`);
|
|
594
|
+
// 3. The files that would bring it back. `--purge` takes config too.
|
|
595
|
+
const vexpDir = path.join(target, ".vexp");
|
|
596
|
+
if (opts.purge) {
|
|
597
|
+
fs.rmSync(vexpDir, { recursive: true, force: true });
|
|
598
|
+
console.log(` files: removed ${vexpDir}`);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
const removed = removeIndexArtifacts(vexpDir);
|
|
602
|
+
console.log(` files: ${removed.length ? `removed ${removed.join(", ")}` : "no index files"}${home ? " (home directory: config, licence and registry untouched)" : ""}`);
|
|
603
|
+
}
|
|
604
|
+
console.log(chalk.green(`✓ ${target} forgotten`));
|
|
605
|
+
if (home) {
|
|
606
|
+
console.log(chalk.dim(" Nothing starts a daemon on the home directory any more: every launcher refuses it (VEXP_ALLOW_HOME_WORKSPACE=1 overrides)."));
|
|
607
|
+
}
|
|
608
|
+
});
|
|
552
609
|
program
|
|
553
610
|
.command("shield <action>")
|
|
554
611
|
.description("PII/secret shield: 'scan' reports what comments and string literals would hand to any AI agent (free, read-only, local)")
|
|
@@ -1255,7 +1312,16 @@ program
|
|
|
1255
1312
|
const binaryPath = ensureBinary();
|
|
1256
1313
|
const mcpServerPath = getMcpServerPath();
|
|
1257
1314
|
// Import lazily so configureCodexGlobal's side effects (TOML rewrite + legacy cleanup) only fire here.
|
|
1258
|
-
const { configureSelectedAgents } = await import("./agent-config.js");
|
|
1315
|
+
const { configureSelectedAgents, codexGlobalSectionIsUserManaged } = await import("./agent-config.js");
|
|
1316
|
+
// A hand-written [mcp_servers.vexp] is never rewritten (no-clobber), and
|
|
1317
|
+
// this command used to print its success line anyway — the user then
|
|
1318
|
+
// "repointed" Codex and watched it launch from the old cwd (field
|
|
1319
|
+
// report, 2026-09-19). Say what did not happen and how to do it by hand.
|
|
1320
|
+
if (codexGlobalSectionIsUserManaged()) {
|
|
1321
|
+
console.error(chalk.yellow(`~/.codex/config.toml has a hand-written [mcp_servers.vexp] section, and vexp never rewrites one: Codex keeps launching it exactly as written, so nothing changed.`));
|
|
1322
|
+
console.error(chalk.dim(` Point it yourself — under [mcp_servers.vexp] add cwd = ${JSON.stringify(resolved)}, and under [mcp_servers.vexp.env] add VEXP_WORKSPACE = ${JSON.stringify(resolved)} — or delete the section and run 'vexp use' again.`));
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1259
1325
|
const version = (await getInstalledVersion()) ?? CLI_VERSION;
|
|
1260
1326
|
// "Codex" is a no-op file-wise in non-detect mode but the shared flow
|
|
1261
1327
|
// also writes codex global config via configureCodexGlobal — pass an
|
package/dist/doctor.js
CHANGED
|
@@ -10,6 +10,8 @@ import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js
|
|
|
10
10
|
import { CLI_VERSION } from "./version.js";
|
|
11
11
|
import { parentPid } from "./mcp-supervisor.js";
|
|
12
12
|
import { slowMountNotice } from "./slow-mount.js";
|
|
13
|
+
import { vexpHome, isStateHome } from "./state-home.js";
|
|
14
|
+
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
13
15
|
// `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
|
|
14
16
|
// Surfaces the failure modes behind the Codex drift report: stale daemons.json
|
|
15
17
|
// entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
|
|
@@ -17,12 +19,6 @@ import { slowMountNotice } from "./slow-mount.js";
|
|
|
17
19
|
const OK = chalk.green("OK");
|
|
18
20
|
const WARN = chalk.yellow("WARN");
|
|
19
21
|
const BAD = chalk.red("FAIL");
|
|
20
|
-
function vexpHome() {
|
|
21
|
-
const h = process.env.VEXP_HOME;
|
|
22
|
-
if (h && path.isAbsolute(h))
|
|
23
|
-
return h;
|
|
24
|
-
return os.homedir();
|
|
25
|
-
}
|
|
26
22
|
/** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
|
|
27
23
|
* then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
|
|
28
24
|
function discoverWorkspaceRoot(start) {
|
|
@@ -297,7 +293,7 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
|
|
|
297
293
|
if (script && !exists(script))
|
|
298
294
|
return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
|
|
299
295
|
const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
|
|
300
|
-
if (pinned &&
|
|
296
|
+
if (pinned && !samePath(pinned, wsRoot)) {
|
|
301
297
|
return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
|
|
302
298
|
}
|
|
303
299
|
return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
|
|
@@ -319,8 +315,9 @@ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec
|
|
|
319
315
|
return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
|
|
320
316
|
}
|
|
321
317
|
function samePath(a, b) {
|
|
322
|
-
// The daemon registry lower-cases Windows roots
|
|
323
|
-
|
|
318
|
+
// The daemon registry lower-cases Windows roots and spells a mapped drive
|
|
319
|
+
// as its share; compare accordingly.
|
|
320
|
+
return canonicalWorkspaceRoot(path.resolve(a)).toLowerCase() === canonicalWorkspaceRoot(path.resolve(b)).toLowerCase();
|
|
324
321
|
}
|
|
325
322
|
/**
|
|
326
323
|
* A connected repo of a multi-repo workspace has no daemon of its own: the
|
|
@@ -638,7 +635,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
638
635
|
// disagrees with this directory's natural target.
|
|
639
636
|
if (ws.source === "VEXP_WORKSPACE") {
|
|
640
637
|
const discovered = discoverWorkspaceRoot(process.cwd());
|
|
641
|
-
if (discovered
|
|
638
|
+
if (!samePath(discovered, ws.root)) {
|
|
642
639
|
line(WARN, `global VEXP_WORKSPACE env var pins this session to ${ws.root}, but this directory's natural target is ${discovered} → agents here get [] / wrong-repo results. Unset the VEXP_WORKSPACE OS/shell env var for per-session targeting.`);
|
|
643
640
|
}
|
|
644
641
|
}
|
|
@@ -773,8 +770,15 @@ async function doctorChecks(onWorkspace) {
|
|
|
773
770
|
return false;
|
|
774
771
|
} })();
|
|
775
772
|
line(stale ? WARN : OK, `${root} → ${s}${stale ? " (STALE: socket file gone)" : ""}`);
|
|
773
|
+
// A row for the home directory is never right: its .vexp is vexp's
|
|
774
|
+
// state dir, and a daemon there indexes everything under home. Every
|
|
775
|
+
// launcher now refuses it; the row and the daemon behind it are the
|
|
776
|
+
// residue (field report, 2026-09-19).
|
|
777
|
+
if (isStateHome(root)) {
|
|
778
|
+
line(BAD, `${root} is your home directory — never a workspace (its .vexp is vexp's state dir). A daemon here indexes everything under home. Run: vexp forget "${root}" (stops it, drops this row, deletes only its index files — licence, config and registry stay)`);
|
|
779
|
+
}
|
|
776
780
|
}
|
|
777
|
-
const inReg = entries.some(([r]) => r
|
|
781
|
+
const inReg = entries.some(([r]) => samePath(r, ws.root));
|
|
778
782
|
if (!inReg)
|
|
779
783
|
line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
|
|
780
784
|
// Multi-daemon + global pin: catches the case the section-1 check misses
|
|
@@ -837,7 +841,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
837
841
|
continue;
|
|
838
842
|
}
|
|
839
843
|
const toml = fs.readFileSync(file, "utf-8");
|
|
840
|
-
|
|
844
|
+
// The section INCLUDES its own subsections ([mcp_servers.vexp.env],
|
|
845
|
+
// .http_headers): the pin lives in .env, and a lookahead that stopped at
|
|
846
|
+
// any '[' hid it, so doctor reported "stdio (command)" for a stanza it
|
|
847
|
+
// could not tell apart from an unpinned one.
|
|
848
|
+
const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[(?!mcp_servers\.vexp\.)|$)/);
|
|
841
849
|
const section = m ? m[0] : "";
|
|
842
850
|
if (!section) {
|
|
843
851
|
line(OK, `${label}: no [mcp_servers.vexp] stanza`);
|
|
@@ -852,7 +860,9 @@ async function doctorChecks(onWorkspace) {
|
|
|
852
860
|
line(OK, `${label}: transport http (url)`);
|
|
853
861
|
else if (hasCmd) {
|
|
854
862
|
const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
|
|
855
|
-
|
|
863
|
+
const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
|
|
864
|
+
const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
|
|
865
|
+
line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin — the server resolves from Codex's cwd"}`);
|
|
856
866
|
}
|
|
857
867
|
else
|
|
858
868
|
line(WARN, `${label}: stanza present but neither url nor command found`);
|
package/dist/forget.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { vexpHome } from "./state-home.js";
|
|
4
|
+
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
5
|
+
/**
|
|
6
|
+
* `vexp forget`: the pieces that make a workspace stay forgotten.
|
|
7
|
+
*
|
|
8
|
+
* Stopping a daemon is not enough on a machine running `vexp serve`: the
|
|
9
|
+
* supervisor resurrects every registry row whose manifest still exists,
|
|
10
|
+
* within a minute. Removing the registry row by hand is not enough either:
|
|
11
|
+
* a daemon that is still alive is written back on the next health tick, and
|
|
12
|
+
* the daemon re-registers itself on every start. What keeps a workspace
|
|
13
|
+
* down is all three at once — daemon stopped, row gone, manifest and index
|
|
14
|
+
* gone — and that is what `vexp forget` does (field report, 2026-09-19: a
|
|
15
|
+
* user did each of the three by hand, in turn, four times).
|
|
16
|
+
*/
|
|
17
|
+
/** Everything a daemon or an index run leaves in `<workspace>/.vexp`.
|
|
18
|
+
* Config is absent on purpose (`vexp.toml`, `workspace.json`,
|
|
19
|
+
* `parent_workspace.json`, `.gitattributes`, `.gitignore`): forgetting a
|
|
20
|
+
* workspace removes what brings its daemon back, not what the user wrote.
|
|
21
|
+
* At the home directory the same `.vexp` is vexp's state dir, and this list
|
|
22
|
+
* is exactly what may go there — `config.toml`, the licence tokens,
|
|
23
|
+
* `daemons.json`, `mcp.pid`/`mcp.token`, `gpu-unusable`, models and plugins
|
|
24
|
+
* are not on it. Keep in sync with vexp-core hook_installer VEXP_IGNORE_ENTRIES. */
|
|
25
|
+
export const INDEX_ARTIFACTS = [
|
|
26
|
+
"index.db",
|
|
27
|
+
"index.db-wal",
|
|
28
|
+
"index.db-shm",
|
|
29
|
+
"index.lock",
|
|
30
|
+
"manifest.json",
|
|
31
|
+
"coverage.json",
|
|
32
|
+
"daemon.pid",
|
|
33
|
+
"daemon.pipe",
|
|
34
|
+
"daemon.sock",
|
|
35
|
+
"healthy",
|
|
36
|
+
"start-blocked",
|
|
37
|
+
"daemon-job-warning",
|
|
38
|
+
"mcp.port",
|
|
39
|
+
"daily-limit.json",
|
|
40
|
+
];
|
|
41
|
+
/** Rotating logs and per-session markers, matched by prefix. */
|
|
42
|
+
export const INDEX_ARTIFACT_PREFIXES = [
|
|
43
|
+
"daemon.log",
|
|
44
|
+
"vexp.log",
|
|
45
|
+
"stop-gate-",
|
|
46
|
+
"idle-gate-",
|
|
47
|
+
"task-",
|
|
48
|
+
];
|
|
49
|
+
/** Per-session scratch directories the hooks write. */
|
|
50
|
+
export const INDEX_ARTIFACT_DIRS = ["edit-hints", "search-hints", "read-hints"];
|
|
51
|
+
/** True when an entry of `.vexp/` is index residue rather than config. */
|
|
52
|
+
export function isIndexArtifact(name, isDir) {
|
|
53
|
+
if (isDir)
|
|
54
|
+
return INDEX_ARTIFACT_DIRS.includes(name);
|
|
55
|
+
if (INDEX_ARTIFACTS.includes(name))
|
|
56
|
+
return true;
|
|
57
|
+
return INDEX_ARTIFACT_PREFIXES.some((p) => name === p || name.startsWith(p));
|
|
58
|
+
}
|
|
59
|
+
/** Delete the index artifacts in `vexpDir`, nothing else. Returns what went. */
|
|
60
|
+
export function removeIndexArtifacts(vexpDir) {
|
|
61
|
+
const removed = [];
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fs.readdirSync(vexpDir, { withFileTypes: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (!isIndexArtifact(e.name, e.isDirectory()))
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
fs.rmSync(path.join(vexpDir, e.name), { recursive: true, force: true });
|
|
74
|
+
removed.push(e.name);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* a file the daemon still holds on Windows; reported by omission */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return removed;
|
|
81
|
+
}
|
|
82
|
+
function registryFile() {
|
|
83
|
+
return path.join(vexpHome(), ".vexp", "daemons.json");
|
|
84
|
+
}
|
|
85
|
+
/** The registry without every row that names `target` or routes to its
|
|
86
|
+
* socket (a connected repo mapped to this workspace's socket is dangling
|
|
87
|
+
* once the workspace is gone). Windows keys are lowercased by the daemon. */
|
|
88
|
+
export function registryRowsWithout(reg, target, targetSocket) {
|
|
89
|
+
// A mapped drive is registered as its share; canonicalize before comparing.
|
|
90
|
+
const canon = (p) => (process.platform === "win32" ? canonicalWorkspaceRoot(path.resolve(p)).toLowerCase() : path.resolve(p));
|
|
91
|
+
const t = canon(target);
|
|
92
|
+
const kept = {};
|
|
93
|
+
const dropped = [];
|
|
94
|
+
for (const [ws, sock] of Object.entries(reg)) {
|
|
95
|
+
if (canon(ws) === t || sock === targetSocket)
|
|
96
|
+
dropped.push(ws);
|
|
97
|
+
else
|
|
98
|
+
kept[ws] = sock;
|
|
99
|
+
}
|
|
100
|
+
return { kept, dropped };
|
|
101
|
+
}
|
|
102
|
+
/** Apply `registryRowsWithout` to ~/.vexp/daemons.json. */
|
|
103
|
+
export function forgetRegistryRows(target, targetSocket) {
|
|
104
|
+
const p = registryFile();
|
|
105
|
+
let reg = {};
|
|
106
|
+
try {
|
|
107
|
+
reg = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const { kept, dropped } = registryRowsWithout(reg, target, targetSocket);
|
|
113
|
+
if (dropped.length === 0)
|
|
114
|
+
return [];
|
|
115
|
+
try {
|
|
116
|
+
fs.writeFileSync(p, JSON.stringify(kept, null, 2), "utf-8");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
return dropped;
|
|
122
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as path from "path";
|
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
import { getBinaryPath, binaryEnv } from "./binary.js";
|
|
7
7
|
import { ensureMcpHttpServer } from "./mcp-supervisor.js";
|
|
8
|
+
import { isStateHome } from "./state-home.js";
|
|
9
|
+
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
8
10
|
const HEALTH_INTERVAL_MS = 60_000;
|
|
9
11
|
const DEFAULT_MCP_PORT = 7821;
|
|
10
12
|
function registryPath() {
|
|
@@ -187,6 +189,22 @@ export function isLinkedWorktree(ws) {
|
|
|
187
189
|
return false;
|
|
188
190
|
}
|
|
189
191
|
}
|
|
192
|
+
/** Why a registry row must never be spawned by this supervisor, or null.
|
|
193
|
+
* The home directory is refused by the daemon itself (vexp-core
|
|
194
|
+
* forbidden_workspace_reason); vetoing it here too spares a spawn per
|
|
195
|
+
* minute that would only write "daemon did not start" to ~/.vexp
|
|
196
|
+
* (field report, 2026-09-19: the supervisor was one of the launchers
|
|
197
|
+
* that kept bringing the home daemon back). */
|
|
198
|
+
export function resurrectionVeto(ws) {
|
|
199
|
+
if (isStateHome(ws)) {
|
|
200
|
+
return "the home directory is vexp's state dir, never a workspace — `vexp forget` removes this row";
|
|
201
|
+
}
|
|
202
|
+
if (!fs.existsSync(path.join(ws, ".vexp", "manifest.json")))
|
|
203
|
+
return "no manifest";
|
|
204
|
+
if (isLinkedWorktree(ws))
|
|
205
|
+
return "linked git worktree — on-demand only, never resurrected";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
190
208
|
export function resurrectionPlan(reg) {
|
|
191
209
|
const bySock = new Map();
|
|
192
210
|
for (const [ws, sock] of Object.entries(reg)) {
|
|
@@ -224,19 +242,15 @@ async function resurrectAll() {
|
|
|
224
242
|
// rewrite here would otherwise immortalize the phantom twin.
|
|
225
243
|
const seenKeys = new Set();
|
|
226
244
|
for (const [ws, sock] of Object.entries(reg)) {
|
|
227
|
-
const canonical = process.platform === "win32" ? ws.toLowerCase() : ws;
|
|
245
|
+
const canonical = process.platform === "win32" ? canonicalWorkspaceRoot(ws).toLowerCase() : ws;
|
|
228
246
|
if (seenKeys.has(canonical)) {
|
|
229
247
|
appendLog(`registry prune: ${ws} (case-variant duplicate)`);
|
|
230
248
|
continue;
|
|
231
249
|
}
|
|
232
250
|
seenKeys.add(canonical);
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
appendLog(`registry prune: ${ws} (
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
if (isLinkedWorktree(ws)) {
|
|
239
|
-
appendLog(`registry prune: ${ws} (linked git worktree — on-demand only, never resurrected)`);
|
|
251
|
+
const veto = resurrectionVeto(ws);
|
|
252
|
+
if (veto) {
|
|
253
|
+
appendLog(`registry prune: ${ws} (${veto})`);
|
|
240
254
|
continue;
|
|
241
255
|
}
|
|
242
256
|
pruned[ws] = sock;
|
|
@@ -286,7 +300,7 @@ async function resurrectAll() {
|
|
|
286
300
|
* report what it could not find, never delete what it does not own.
|
|
287
301
|
*/
|
|
288
302
|
export async function survivingRegistryRows(before, resurrected, alive, log = () => { }) {
|
|
289
|
-
const canonical = (ws) => (process.platform === "win32" ? ws.toLowerCase() : ws);
|
|
303
|
+
const canonical = (ws) => (process.platform === "win32" ? canonicalWorkspaceRoot(ws).toLowerCase() : ws);
|
|
290
304
|
const out = { ...resurrected };
|
|
291
305
|
const kept = new Set(Object.keys(out).map(canonical));
|
|
292
306
|
for (const [ws, sock] of Object.entries(before)) {
|
package/dist/socket-path.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
1
2
|
import * as path from "path";
|
|
2
3
|
/**
|
|
3
4
|
* Where the daemon listens for a given workspace root.
|
|
@@ -18,7 +19,7 @@ import * as path from "path";
|
|
|
18
19
|
*/
|
|
19
20
|
export function socketPathFor(workspaceRoot) {
|
|
20
21
|
if (process.platform === "win32") {
|
|
21
|
-
return `\\\\.\\pipe\\vexp-${fnvHash(workspaceRoot.toLowerCase()).slice(0, 8)}`;
|
|
22
|
+
return `\\\\.\\pipe\\vexp-${fnvHash(canonicalWorkspaceRoot(workspaceRoot).toLowerCase()).slice(0, 8)}`;
|
|
22
23
|
}
|
|
23
24
|
const candidate = path.join(workspaceRoot, ".vexp", "daemon.sock");
|
|
24
25
|
// macOS/BSD sockaddr_un caps the path at 104 bytes; vexp-core falls back to
|
|
@@ -27,6 +28,29 @@ export function socketPathFor(workspaceRoot) {
|
|
|
27
28
|
return candidate;
|
|
28
29
|
return `/tmp/vexp-${fnvHash(workspaceRoot).slice(0, 12)}.sock`;
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The workspace root as the daemon spells it. vexp-core canonicalizes the
|
|
33
|
+
* root before deriving the pipe name and the daemons.json key, and on
|
|
34
|
+
* Windows a folder on a mapped drive canonicalizes to its share:
|
|
35
|
+
* `X:\proj` becomes `\\server\share\proj`. Every hash computed from the
|
|
36
|
+
* drive-letter spelling named a pipe the daemon never bound, so `vexp
|
|
37
|
+
* status` on a share reported the daemon as down and started a second one,
|
|
38
|
+
* and the MCP bridge could not reach it (field report, Samba share from
|
|
39
|
+
* Windows 11, 2026-09-20). Node's native realpath resolves the same way; a
|
|
40
|
+
* local path only gets its case corrected, which the lowercase hash never
|
|
41
|
+
* saw. Falls back to the spelling given when the folder cannot be resolved
|
|
42
|
+
* (a stale registry row, a path that is not there yet).
|
|
43
|
+
*/
|
|
44
|
+
export function canonicalWorkspaceRoot(root) {
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
return root;
|
|
47
|
+
try {
|
|
48
|
+
return fs.realpathSync.native(root);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
30
54
|
/** Longest `<root>/.vexp/daemon.sock` vexp binds in place; past it the socket lives in /tmp. */
|
|
31
55
|
export const UNIX_SOCKET_PATH_LIMIT = 100;
|
|
32
56
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/** The directory vexp keeps its own state in (`<here>/.vexp`): VEXP_HOME when
|
|
4
|
+
* set to an absolute path, else the OS home. */
|
|
5
|
+
export function vexpHome() {
|
|
6
|
+
const h = process.env.VEXP_HOME;
|
|
7
|
+
if (h && path.isAbsolute(h))
|
|
8
|
+
return h;
|
|
9
|
+
return os.homedir();
|
|
10
|
+
}
|
|
11
|
+
/** True when `dir` IS the home / VEXP_HOME directory. Its `.vexp` is vexp's
|
|
12
|
+
* state dir (config, licence, daemons.json), never a workspace: a daemon
|
|
13
|
+
* there indexes everything under home, and deleting the directory to be rid
|
|
14
|
+
* of it takes the licence and the registry along (field report, 2026-09-19).
|
|
15
|
+
* Lockstep with vexp-core utils::forbidden_workspace_reason and
|
|
16
|
+
* vexp-mcp isVexpStateHome; VEXP_ALLOW_HOME_WORKSPACE=1 lifts it everywhere. */
|
|
17
|
+
export function isStateHome(dir) {
|
|
18
|
+
if (process.env.VEXP_ALLOW_HOME_WORKSPACE === "1")
|
|
19
|
+
return false;
|
|
20
|
+
const a = path.resolve(dir);
|
|
21
|
+
const b = path.resolve(vexpHome());
|
|
22
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
23
|
+
}
|