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.
@@ -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
- const input = workspaceRoot.toLowerCase();
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 && path.resolve(pinned).toLowerCase() !== path.resolve(wsRoot).toLowerCase()) {
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; compare accordingly.
323
- return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
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.toLowerCase() !== ws.root.toLowerCase()) {
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.toLowerCase() === ws.root.toLowerCase());
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
- const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
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
- line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
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 manifest = path.join(ws, ".vexp", "manifest.json");
234
- if (!fs.existsSync(manifest)) {
235
- appendLog(`registry prune: ${ws} (no manifest)`);
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)) {
@@ -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
+ }