vexp-cli 2.2.0 → 2.2.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.
@@ -10,7 +10,7 @@ import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import * as os from "os";
12
12
  import * as crypto from "crypto";
13
- import { VEXP_GUARD_HOOK } from "./hook-template.js";
13
+ import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD } from "./hook-template.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Constants
16
16
  // ---------------------------------------------------------------------------
@@ -47,7 +47,11 @@ function workspaceHash(workspaceRoot) {
47
47
  hash ^= BigInt(byte);
48
48
  hash = (hash * prime) & mask;
49
49
  }
50
- return hash.toString(16).padStart(16, "0").slice(0, 8);
50
+ // NOT zero-padded — see doctor.ts:socketForWorkspace. Rust renders the u64
51
+ // with `format!("{:x}")` and slices [..8]; padding to 16 first shifts every
52
+ // digit right whenever the hash has a leading zero nibble (~6% of paths),
53
+ // which silently breaks the lockstep this comment demands.
54
+ return hash.toString(16).slice(0, 8);
51
55
  }
52
56
  /**
53
57
  * Remove every legacy vexp entry from a JSON-shaped MCP config object.
@@ -150,8 +154,13 @@ const AGENT_DETECTORS = [
150
154
  {
151
155
  agent: "Opencode",
152
156
  detectPath: "opencode.json",
157
+ // opencode also reads `opencode.jsonc` (and a global ~/.config/opencode/),
158
+ // so `opencode.json` alone missed the many users who keep a commented config.
159
+ detectPaths: ["opencode.jsonc"],
153
160
  configFile: "AGENTS.md",
154
161
  templateName: "agents-md",
162
+ // Enforcement (block grep/glob when the daemon is up) is installed as an
163
+ // opencode plugin at .opencode/plugins/vexp-guard.js — see installOpencodePlugin().
155
164
  },
156
165
  {
157
166
  agent: "Kilo Code",
@@ -337,6 +346,20 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
337
346
  if (wrote)
338
347
  mcpConfigs.push(wrote);
339
348
  }
349
+ // Opencode: install the guard plugin that blocks grep/glob while the daemon
350
+ // is up (opencode's analogue of the Claude Code PreToolUse hook).
351
+ if (detector.agent === "Opencode") {
352
+ const pluginResult = installOpencodePlugin(workspaceRoot);
353
+ if (pluginResult) {
354
+ results.push({
355
+ agent: "Opencode Guard",
356
+ configFile: ".opencode/plugins/vexp-guard.js",
357
+ content: VEXP_OPENCODE_GUARD,
358
+ alreadyExists: pluginResult === "updated",
359
+ action: pluginResult,
360
+ });
361
+ }
362
+ }
340
363
  results.push({
341
364
  agent: detector.agent,
342
365
  configFile: detector.configFile,
@@ -445,6 +468,9 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
445
468
  if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
446
469
  mcpConfigs.push(".zed/settings.json");
447
470
  }
471
+ if (detector.agent === "Opencode") {
472
+ installOpencodePlugin(workspaceRoot);
473
+ }
448
474
  results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
449
475
  }
450
476
  return { agents: results, mcpConfigs };
@@ -537,6 +563,65 @@ export function readJsonConfigSafe(filePath) {
537
563
  return { data: {}, ok: false, existed: true };
538
564
  }
539
565
  /** Copy an existing config to <file>.vexp-bak before overwriting it. */
566
+ /**
567
+ * True when the vexp entry already in a config still resolves to something
568
+ * runnable — even if it points at a DIFFERENT vexp install than ours.
569
+ *
570
+ * Mirrors `vexpEntryStillResolves` in
571
+ * packages/vexp-vscode/src/providers/agent-auto-config.ts; duplicated because
572
+ * the CLI cannot depend on the extension at runtime.
573
+ *
574
+ * `mcp-server.cjs` is a standalone script that reaches the daemon over its
575
+ * socket; it does not care which install it was launched from. So an entry
576
+ * naming another install is not stale, it is just someone else's path — and
577
+ * rewriting it starts a fight the user sees as "my MCP config gets
578
+ * overwritten every time I open a different IDE". Every VS Code fork carries
579
+ * the extension and writes these same files with its own absolute extension
580
+ * path, while the CLI writes `command: "node"` plus its npm-global path — a
581
+ * third shape — so `vexp setup` and any IDE activation also undid each other.
582
+ * The old value-equality check could never settle it: a different path is
583
+ * never identical, so whoever ran last won.
584
+ *
585
+ * Staleness is handled by the check rather than by rewriting: editors reap the
586
+ * previous extension directory on upgrade (and the path embeds the version),
587
+ * so an entry left by an uninstalled build fails here and is rewritten.
588
+ *
589
+ * Accepts every shape we emit:
590
+ * {command: "node", args: ["…/mcp-server.cjs"]} most JSON configs
591
+ * {command: "…/vexp-core", args: ["mcp"]} binary-subcommand
592
+ * {command: {path, args}} Zed
593
+ * {command: ["node", "…/mcp-server.cjs"]} Kilo Code
594
+ */
595
+ function vexpEntryStillResolves(entry) {
596
+ if (!entry || typeof entry !== "object")
597
+ return false;
598
+ const e = entry;
599
+ let command;
600
+ let rawArgs;
601
+ if (Array.isArray(e.command)) {
602
+ command = e.command[0];
603
+ rawArgs = e.command.slice(1);
604
+ }
605
+ else if (e.command !== null && typeof e.command === "object") {
606
+ const nested = e.command;
607
+ command = nested.path;
608
+ rawArgs = nested.args;
609
+ }
610
+ else {
611
+ command = e.command;
612
+ rawArgs = e.args;
613
+ }
614
+ if (typeof command !== "string" || command.length === 0)
615
+ return false;
616
+ // An interpreter pinned by absolute path must still exist. A bare "node" is
617
+ // resolved via PATH at spawn time, so there is nothing to verify here.
618
+ if (path.isAbsolute(command) && !fs.existsSync(command))
619
+ return false;
620
+ // The script it runs, when the entry names one. `args: ["mcp"]` names a
621
+ // subcommand of the binary in `command`, already checked above.
622
+ const script = (Array.isArray(rawArgs) ? rawArgs : []).find((a) => typeof a === "string" && path.isAbsolute(a) && /\.[cm]?js$/.test(a));
623
+ return script === undefined || fs.existsSync(script);
624
+ }
540
625
  function backupConfig(filePath) {
541
626
  try {
542
627
  if (fs.existsSync(filePath))
@@ -685,10 +770,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
685
770
  const currentCmd = previousVexp?.["command"];
686
771
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
687
772
  const prevEnv = previousVexp?.env;
773
+ const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
688
774
  const identical = currentCmd === targetCmd &&
689
775
  prevArgs !== undefined &&
690
776
  JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
691
- JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
777
+ envMatches;
692
778
  // Legacy cleanup: remove any vexp/vexp-* keys; if the canonical 'vexp'
693
779
  // entry points at a command path that no longer exists on disk, drop it
694
780
  // too so we don't keep a stale pointer.
@@ -697,6 +783,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
697
783
  if (identical && removed.length === 0) {
698
784
  return false; // Already up to date and no legacy to clean
699
785
  }
786
+ // Another vexp install already left a working entry here — don't fight it.
787
+ // Only the install path may differ: `envMatches` still gates on the
788
+ // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
789
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
790
+ return false;
700
791
  const servers = existing.mcpServers ?? {};
701
792
  servers["vexp"] = {
702
793
  command: targetCmd,
@@ -765,6 +856,34 @@ function tomlString(value) {
765
856
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
766
857
  }
767
858
  /** Extract the existing canonical [mcp_servers.vexp] block (incl. subsections). */
859
+ /** Inverse of `tomlString` for the two forms it emits: a literal `'…'` (no
860
+ * escapes) or a basic `"…"` with `\\` / `\"` escaped. */
861
+ function parseTomlString(raw) {
862
+ const literal = /^'([^']*)'$/.exec(raw);
863
+ if (literal)
864
+ return literal[1];
865
+ const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
866
+ if (basic)
867
+ return basic[1].replace(/\\(["\\])/g, "$1");
868
+ return undefined;
869
+ }
870
+ /**
871
+ * The binary an existing managed *direct* section points at, when it is still
872
+ * on disk. Undefined for an http section, a malformed one, or one naming a
873
+ * binary that is gone — each of which must be rebuilt from our own path.
874
+ *
875
+ * Mirrors `codexDirectBinary` in
876
+ * packages/vexp-vscode/src/providers/agent-auto-config.ts.
877
+ */
878
+ function codexDirectBinary(section) {
879
+ const m = /^\s*command\s*=\s*(.+?)\s*$/m.exec(section);
880
+ if (!m)
881
+ return undefined;
882
+ const cmd = parseTomlString(m[1]);
883
+ if (!cmd || !path.isAbsolute(cmd) || !fs.existsSync(cmd))
884
+ return undefined;
885
+ return cmd;
886
+ }
768
887
  function extractCodexVexpSection(content) {
769
888
  const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
770
889
  return m ? m.join("\n") : "";
@@ -839,7 +958,7 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
839
958
  // stanza Codex can cache → "url is not supported for stdio". Pass the binary only
840
959
  // when it exists; buildCodexSection(direct, undefined) returns null → we then
841
960
  // PRESERVE the existing managed section instead of clobbering it with a url.
842
- const coreBinaryPath = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
961
+ const ourCoreBinary = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
843
962
  let content = "";
844
963
  if (fs.existsSync(configPath))
845
964
  content = fs.readFileSync(configPath, "utf-8");
@@ -861,6 +980,15 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
861
980
  }
862
981
  return false;
863
982
  }
983
+ // Step 2b: adopt the binary a previous install already pinned here, when it
984
+ // still exists. Our own sections are not "user-managed", so `vexp setup` and
985
+ // every IDE activation rewrote this file with their own binary path — and the
986
+ // file is machine-global while Codex is detected by AGENTS.md, so it churned
987
+ // for every project at once. Adopting rather than skipping is what keeps this
988
+ // safe: the binary is interchangeable, but the workspace pin below is not and
989
+ // must still follow the project being configured. See the twin comment in
990
+ // vexp-vscode/src/providers/agent-auto-config.ts.
991
+ const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
864
992
  const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
865
993
  if (!newSection) {
866
994
  // direct requested but binary missing → preserve the existing section (only
@@ -898,14 +1026,22 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
898
1026
  const beforeServers = existing.servers;
899
1027
  const previousVexp = beforeServers?.["vexp"];
900
1028
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
1029
+ const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
901
1030
  const identical = previousVexp?.["command"] === targetCmd &&
902
1031
  prevArgs !== undefined &&
903
1032
  JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
904
- JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
1033
+ envMatches;
905
1034
  const removed = stripLegacyVexpEntries(existing, "servers");
906
1035
  logRemoval(removed);
907
1036
  if (identical && removed.length === 0)
908
1037
  return false;
1038
+ // Another vexp install already left a working entry here — don't fight it.
1039
+ // This file is the worst offender: every VS Code fork reaches it through the
1040
+ // Copilot branch, which is gated only on `.github/` existing. Only the
1041
+ // install path may differ — `envMatches` still gates on the VEXP_WORKSPACE
1042
+ // pin, so a moved or renamed project is repinned normally.
1043
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1044
+ return false;
909
1045
  const servers = existing.servers ?? {};
910
1046
  servers["vexp"] = {
911
1047
  type: "stdio",
@@ -953,13 +1089,20 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
953
1089
  const env = { VEXP_WORKSPACE: workspaceRoot };
954
1090
  const mcp = cfg.mcp ?? {};
955
1091
  const prev = mcp["vexp"];
1092
+ const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
956
1093
  const identical = prev?.["type"] === "local" &&
957
1094
  prev?.["enabled"] === true &&
958
1095
  Array.isArray(prev?.["command"]) &&
959
1096
  JSON.stringify(prev["command"]) === JSON.stringify(command) &&
960
- JSON.stringify(prev["env"]) === JSON.stringify(env);
1097
+ envMatches;
961
1098
  if (identical)
962
1099
  return null;
1100
+ // Another vexp install already left a working entry here — don't fight it.
1101
+ // Kilo's shape is `command: ["node", "<script>"]`. Only the install path may
1102
+ // differ: `envMatches` still gates on the VEXP_WORKSPACE pin, and `enabled`
1103
+ // must be true or the user turned it off deliberately.
1104
+ if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev))
1105
+ return null;
963
1106
  mcp["vexp"] = { type: "local", command, env, enabled: true };
964
1107
  cfg.mcp = mcp;
965
1108
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -982,14 +1125,21 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
982
1125
  const beforeCs = settings.context_servers;
983
1126
  const previousVexp = beforeCs?.["vexp"];
984
1127
  const curCmd = previousVexp?.["command"];
1128
+ // Zed nests env inside `command`, unlike every other writer.
1129
+ const envMatches = JSON.stringify(curCmd?.["env"]) === JSON.stringify(targetEnv);
985
1130
  const identical = curCmd?.["path"] === targetCmd &&
986
1131
  Array.isArray(curCmd?.["args"]) &&
987
1132
  JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
988
- JSON.stringify(curCmd["env"]) === JSON.stringify(targetEnv);
1133
+ envMatches;
989
1134
  const removed = stripLegacyVexpEntries(settings, "context_servers");
990
1135
  logRemoval(removed);
991
1136
  if (identical && removed.length === 0)
992
1137
  return false;
1138
+ // Another vexp install already left a working entry here — don't fight it.
1139
+ // Only the install path may differ: `envMatches` still gates on the
1140
+ // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1141
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1142
+ return false;
993
1143
  const cs = settings.context_servers ?? {};
994
1144
  cs["vexp"] = {
995
1145
  command: {
@@ -1050,6 +1200,13 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
1050
1200
  logRemoval(removed);
1051
1201
  if (identical && removed.length === 0)
1052
1202
  return false;
1203
+ // Another vexp install already left a working entry here — don't fight it.
1204
+ // `prevEnv === undefined` is load-bearing, not a nicety: an entry still
1205
+ // pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
1206
+ // this file applies to EVERY project and the pin forces all parallel
1207
+ // sessions onto one daemon (the migration described above).
1208
+ if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing))
1209
+ return false;
1053
1210
  const servers = config.mcpServers ?? {};
1054
1211
  servers["vexp"] = {
1055
1212
  command: desiredCommand,
@@ -1180,6 +1337,31 @@ export function installClaudeCodeHook(workspaceRoot) {
1180
1337
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1181
1338
  return existed ? "updated" : "created";
1182
1339
  }
1340
+ // ---------------------------------------------------------------------------
1341
+ // opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
1342
+ // is healthy. opencode has no PreToolUse hook, but auto-loads plugins from
1343
+ // .opencode/plugins/ and lets a `tool.execute.before` hook abort a tool by
1344
+ // throwing — the same enforcement the Claude Code guard hook provides.
1345
+ // ---------------------------------------------------------------------------
1346
+ /**
1347
+ * Install the vexp-guard plugin for opencode.
1348
+ * Writes .opencode/plugins/vexp-guard.js (opencode auto-loads the directory at
1349
+ * startup — no config-file merge required). Returns the action taken, or null
1350
+ * when the file is already byte-identical.
1351
+ */
1352
+ export function installOpencodePlugin(workspaceRoot) {
1353
+ const pluginDir = path.join(workspaceRoot, ".opencode", "plugins");
1354
+ const pluginPath = path.join(pluginDir, "vexp-guard.js");
1355
+ fs.mkdirSync(pluginDir, { recursive: true });
1356
+ const existed = fs.existsSync(pluginPath);
1357
+ if (existed) {
1358
+ const current = fs.readFileSync(pluginPath, "utf-8");
1359
+ if (current === VEXP_OPENCODE_GUARD)
1360
+ return null; // identical - skip
1361
+ }
1362
+ fs.writeFileSync(pluginPath, VEXP_OPENCODE_GUARD, "utf-8");
1363
+ return existed ? "updated" : "created";
1364
+ }
1183
1365
  function generateAgentConfig(template, vars) {
1184
1366
  switch (template) {
1185
1367
  case "claude-code":
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
16
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
17
17
  import { runServe } from "./serve.js";
18
18
  import { runDoctor } from "./doctor.js";
19
+ import { socketPathFor } from "./socket-path.js";
19
20
  import { isTraceEnabled } from "./trace.js";
20
21
  const program = new Command();
21
22
  program
@@ -158,7 +159,9 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
158
159
  /** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
159
160
  function isSocketAlive(socketPath, timeoutMs = 300) {
160
161
  return new Promise((resolve) => {
161
- if (!fs.existsSync(socketPath)) {
162
+ // A Windows named pipe is not a filesystem entry, so the existsSync
163
+ // pre-check would reject every live daemon. Only unix sockets are files.
164
+ if (process.platform !== "win32" && !fs.existsSync(socketPath)) {
162
165
  resolve(false);
163
166
  return;
164
167
  }
@@ -198,8 +201,7 @@ function isDaemonPidAlive(workspaceRoot) {
198
201
  async function ensureDaemonRunning(workspaceRoot, binaryPath) {
199
202
  if (process.env.VEXP_NO_AUTOSTART === "1")
200
203
  return;
201
- const socketPath = path.join(workspaceRoot, ".vexp", "daemon.sock");
202
- if (await isSocketAlive(socketPath))
204
+ if (await isSocketAlive(socketPathFor(workspaceRoot)))
203
205
  return;
204
206
  try {
205
207
  await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], {
@@ -252,8 +254,9 @@ async function startBackgroundServices(binaryPath, workspaceRoot) {
252
254
  const spinnerDaemon = ora("Starting vexp daemon...").start();
253
255
  try {
254
256
  await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], { silent: true, cwd: workspaceRoot });
255
- const socketPath = path.join(workspaceRoot, ".vexp", "daemon.sock");
256
- if (fs.existsSync(socketPath)) {
257
+ // Connect rather than stat: on Windows the daemon binds a named pipe and
258
+ // there is no socket file to find, so this always reported failure.
259
+ if (await isSocketAlive(socketPathFor(workspaceRoot))) {
257
260
  spinnerDaemon.succeed("vexp daemon started");
258
261
  }
259
262
  else {
@@ -1043,8 +1046,7 @@ async function printBanner() {
1043
1046
  console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
1044
1047
  }
1045
1048
  else {
1046
- const socketPath = path.join(ws, ".vexp", "daemon.sock");
1047
- const socketAlive = await isSocketAlive(socketPath);
1049
+ const socketAlive = await isSocketAlive(socketPathFor(ws));
1048
1050
  if (socketAlive) {
1049
1051
  // Display PID + uptime when we can read them; otherwise a plain "running".
1050
1052
  const { pid } = isDaemonPidAlive(ws);
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as net from "net";
5
5
  import chalk from "chalk";
6
+ import { socketPathFor } from "./socket-path.js";
6
7
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
7
8
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
8
9
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -43,20 +44,10 @@ function resolveWorkspace() {
43
44
  return { root: process.env.CLAUDE_PROJECT_DIR, source: "CLAUDE_PROJECT_DIR" };
44
45
  return { root: discoverWorkspaceRoot(process.cwd()), source: "cwd-discovery" };
45
46
  }
46
- function socketForWorkspace(root) {
47
- if (process.platform === "win32") {
48
- // FNV-1a, lowercase — mirrors get_socket_path / fnvHash.
49
- let hash = BigInt("0xcbf29ce484222325");
50
- const prime = BigInt("0x100000001b3");
51
- const mask = BigInt("0xffffffffffffffff");
52
- for (const b of Buffer.from(root.toLowerCase(), "utf-8")) {
53
- hash ^= BigInt(b);
54
- hash = (hash * prime) & mask;
55
- }
56
- return `\\\\.\\pipe\\vexp-${hash.toString(16).padStart(16, "0").slice(0, 8)}`;
57
- }
58
- return path.join(root, ".vexp", "daemon.sock");
59
- }
47
+ // Was a fifth hand-rolled copy of the hash, and one of the two that zero-padded
48
+ // it — so doctor probed a pipe name the daemon never binds for ~6% of Windows
49
+ // workspaces and declared a healthy daemon down.
50
+ const socketForWorkspace = socketPathFor;
60
51
  function reachable(sock, timeoutMs = 600) {
61
52
  return new Promise((resolve) => {
62
53
  const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
@@ -51,3 +51,93 @@ case "$(uname -s 2>/dev/null)" in
51
51
  ;;
52
52
  esac
53
53
  `;
54
+ /**
55
+ * opencode plugin that enforces the same "use run_pipeline, not grep/glob"
56
+ * policy the Claude Code PreToolUse hook applies — opencode has no hook system,
57
+ * but its plugin API exposes a `tool.execute.before` hook where throwing an
58
+ * Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
59
+ * `vexp setup-agents` (opencode auto-loads that directory at startup).
60
+ *
61
+ * It blocks the native `grep`/`glob` tools AND shelled-out tree search
62
+ * (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
63
+ * when the daemon is down so search still works without an index — mirroring
64
+ * VEXP_GUARD_HOOK's healthy-marker + live-endpoint gate.
65
+ *
66
+ * The literal body is deliberately ASCII-only and free of backticks, `${...}`
67
+ * and backslash escapes so it can be embedded verbatim in a TS template literal
68
+ * (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
69
+ * test in packages/vexp-cli/test/hook-template.test.ts). Do NOT introduce any
70
+ * of those characters here without updating that test's un-escape logic.
71
+ */
72
+ export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents').
73
+ // Forces the agent onto vexp's run_pipeline by blocking opencode's native
74
+ // grep/glob (and shelled-out tree search) WHILE the vexp daemon is healthy.
75
+ // Fails OPEN when the daemon is down, so native search still works with no index.
76
+ // Mirrors vexp's Claude Code PreToolUse guard (.claude/hooks/vexp-guard.sh).
77
+ import { existsSync, readFileSync } from "node:fs";
78
+ import { join, dirname } from "node:path";
79
+
80
+ // Walk up from start to the first ancestor that owns a .vexp dir.
81
+ function findVexpDir(start) {
82
+ let dir = start;
83
+ for (;;) {
84
+ if (existsSync(join(dir, ".vexp"))) return join(dir, ".vexp");
85
+ const parent = dirname(dir);
86
+ if (parent === dir) return null;
87
+ dir = parent;
88
+ }
89
+ }
90
+
91
+ // Is the pid in .vexp/daemon.pid a live process? Guards stale files left after
92
+ // a kill -9: healthy + socket can linger, so existence alone is not enough.
93
+ function pidAlive(pidFile) {
94
+ let pid = 0;
95
+ try { pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
96
+ if (!pid) return false;
97
+ try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
98
+ }
99
+
100
+ // The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
101
+ // Unix: also require a live socket AND a live pid. Windows has no socket file,
102
+ // so the daemon drops a .vexp/daemon.pipe marker instead. A dead daemon fails
103
+ // open so native search still works.
104
+ function daemonHealthy(vexpDir) {
105
+ if (!vexpDir || !existsSync(join(vexpDir, "healthy"))) return false;
106
+ if (process.platform === "win32") return existsSync(join(vexpDir, "daemon.pipe"));
107
+ return existsSync(join(vexpDir, "daemon.sock")) && pidAlive(join(vexpDir, "daemon.pid"));
108
+ }
109
+
110
+ const SEARCH_TOOLS = new Set(["grep", "glob"]);
111
+ const SHELL_SEARCH_BINS = ["rg", "grep", "egrep", "fgrep", "ag", "ack", "find", "fd", "fdfind"];
112
+
113
+ // True when a bash command's first token is a tree-search binary (grep/rg/find).
114
+ function isShellSearch(command) {
115
+ const t = (command || "").trim();
116
+ for (const b of SHELL_SEARCH_BINS) {
117
+ if (t === b || t.indexOf(b + " ") === 0) return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ export const VexpGuard = async ({ directory, worktree }) => {
123
+ const root = worktree || directory || ".";
124
+ let vexpDir = null;
125
+ return {
126
+ "tool.execute.before": async (input, output) => {
127
+ if (!vexpDir) vexpDir = findVexpDir(root);
128
+ if (!daemonHealthy(vexpDir)) return;
129
+ if (SEARCH_TOOLS.has(input.tool)) {
130
+ throw new Error(
131
+ "vexp daemon is running - call run_pipeline instead of " + input.tool +
132
+ " (pre-indexed, graph-ranked, ~60% fewer tokens)."
133
+ );
134
+ }
135
+ if (input.tool === "bash" && isShellSearch(output && output.args && output.args.command)) {
136
+ throw new Error(
137
+ "vexp daemon is running - call run_pipeline instead of shell search (grep/rg/find)."
138
+ );
139
+ }
140
+ },
141
+ };
142
+ };
143
+ `;
@@ -0,0 +1,40 @@
1
+ import * as path from "path";
2
+ /**
3
+ * Where the daemon listens for a given workspace root.
4
+ *
5
+ * MUST stay in lockstep with `get_socket_path` in vexp-core/src/utils.rs.
6
+ * Duplicated rather than imported because the CLI cannot depend on the MCP
7
+ * package at runtime; `test/workspace-hash-lockstep.test.ts` pins the parts
8
+ * that silently drift.
9
+ *
10
+ * Two things here are easy to get wrong, and both were:
11
+ *
12
+ * - Windows has no socket file. The daemon binds a named pipe whose name is
13
+ * the FNV-1a hash of the LOWERCASED root (drive letters vary by caller).
14
+ * Sites that hardcoded `.vexp/daemon.sock` probed a file that never exists
15
+ * on Windows, so the CLI reported a healthy daemon as stopped.
16
+ * - The hash is NOT zero-padded: Rust renders the u64 with `format!("{:x}")`
17
+ * and slices [..8].
18
+ */
19
+ export function socketPathFor(workspaceRoot) {
20
+ if (process.platform === "win32") {
21
+ return `\\\\.\\pipe\\vexp-${fnvHash(workspaceRoot.toLowerCase()).slice(0, 8)}`;
22
+ }
23
+ const candidate = path.join(workspaceRoot, ".vexp", "daemon.sock");
24
+ // macOS/BSD sockaddr_un caps the path at 104 bytes; vexp-core falls back to
25
+ // /tmp past 100 chars, so a deeply-nested workspace listens there instead.
26
+ if (candidate.length <= 100)
27
+ return candidate;
28
+ return `/tmp/vexp-${fnvHash(workspaceRoot).slice(0, 12)}.sock`;
29
+ }
30
+ /** FNV-1a 64-bit, hex, unpadded — mirrors md5_hash() in vexp-core/src/utils.rs. */
31
+ export function fnvHash(input) {
32
+ let hash = BigInt("0xcbf29ce484222325");
33
+ const prime = BigInt("0x100000001b3");
34
+ const mask = BigInt("0xffffffffffffffff");
35
+ for (const byte of Buffer.from(input, "utf-8")) {
36
+ hash ^= BigInt(byte);
37
+ hash = (hash * prime) & mask;
38
+ }
39
+ return hash.toString(16);
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Vexp — Context Engine for AI Coding Agents. Pre-indexes your codebase into a dependency graph and delivers ranked context to any MCP-compatible agent. 58% lower cost per task, 90% fewer tool calls (SWE-bench Verified). Works with Claude Code, Cursor, Copilot, Windsurf, Codex, Cline, Aider, and 12+ agents. Local-first. Your code never leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,10 +100,10 @@
100
100
  "node": ">=20.0.0"
101
101
  },
102
102
  "optionalDependencies": {
103
- "@vexp/core-linux-x64": "2.2.0",
104
- "@vexp/core-linux-arm64": "2.2.0",
105
- "@vexp/core-darwin-x64": "2.2.0",
106
- "@vexp/core-darwin-arm64": "2.2.0",
107
- "@vexp/core-win32-x64": "2.2.0"
103
+ "@vexp/core-linux-x64": "2.2.1",
104
+ "@vexp/core-linux-arm64": "2.2.1",
105
+ "@vexp/core-darwin-x64": "2.2.1",
106
+ "@vexp/core-darwin-arm64": "2.2.1",
107
+ "@vexp/core-win32-x64": "2.2.1"
108
108
  }
109
109
  }