vexp-cli 2.2.0 → 2.2.2

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/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,144 @@ 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'; it is rewritten
73
+ // on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
74
+ //
75
+ // Blocks opencode's native grep/glob ONLY where vexp has a better answer: indexed
76
+ // source inside the workspace, while the daemon is healthy. Two deliberate limits:
77
+ //
78
+ // 1. It never touches bash. Shelling out is the escape hatch for everything vexp
79
+ // cannot index - runtime logs, build output, files outside the repo. vexp's
80
+ // Claude Code guard has always matched Grep|Glob|Regex only, and it works
81
+ // precisely because that valve stays open. Blocking shell search instead
82
+ // teaches the agent to evade (any prefix defeats it) and strands it when the
83
+ // answer genuinely is not in the index.
84
+ // 2. It only blocks targets vexp actually indexed. A search aimed at a log file,
85
+ // at dist/ or node_modules, or outside the workspace has no run_pipeline
86
+ // answer, so blocking it would be a dead end rather than a redirect.
87
+ //
88
+ // Fails OPEN when the daemon is down, so native search still works with no index.
89
+ import { existsSync, readFileSync } from "node:fs";
90
+ import { join, dirname, relative, isAbsolute, sep } from "node:path";
91
+
92
+ // Walk up from start to the first ancestor that owns a .vexp dir.
93
+ function findVexpDir(start) {
94
+ let dir = start;
95
+ for (;;) {
96
+ if (existsSync(join(dir, ".vexp"))) return join(dir, ".vexp");
97
+ const parent = dirname(dir);
98
+ if (parent === dir) return null;
99
+ dir = parent;
100
+ }
101
+ }
102
+
103
+ // Is the pid in .vexp/daemon.pid a live process? Guards stale files left after
104
+ // a kill -9: healthy + socket can linger, so existence alone is not enough.
105
+ function pidAlive(pidFile) {
106
+ let pid = 0;
107
+ try { pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
108
+ if (!pid) return false;
109
+ try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
110
+ }
111
+
112
+ // The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
113
+ // Unix: also require a live socket AND a live pid. Windows has no socket file,
114
+ // so the daemon drops a .vexp/daemon.pipe marker instead. A dead daemon fails
115
+ // open so native search still works.
116
+ function daemonHealthy(vexpDir) {
117
+ if (!vexpDir || !existsSync(join(vexpDir, "healthy"))) return false;
118
+ if (process.platform === "win32") return existsSync(join(vexpDir, "daemon.pipe"));
119
+ return existsSync(join(vexpDir, "daemon.sock")) && pidAlive(join(vexpDir, "daemon.pid"));
120
+ }
121
+
122
+ const SEARCH_TOOLS = new Set(["grep", "glob"]);
123
+
124
+ // Directory names vexp never indexes: build output and vendored dependencies.
125
+ const NON_INDEXED_DIRS = [
126
+ "node_modules", "dist", "build", "out", "coverage", "target", "vendor",
127
+ ".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
128
+ ];
129
+
130
+ // File kinds vexp never indexes. Logs are the case agents legitimately grep the
131
+ // most while debugging a running app. "-lock." is not redundant with ".lock":
132
+ // the two commonest JS lockfiles (package-lock.json, pnpm-lock.yaml) have no dot
133
+ // before "lock", so ".lock" alone let yarn.lock and Cargo.lock through while
134
+ // blocking them.
135
+ const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
136
+
137
+ // Is this search target inside what vexp indexed? Only then is "call
138
+ // run_pipeline instead" real advice rather than a dead end. Anything that cannot
139
+ // be resolved confidently returns false (allow) - under-blocking just leaves the
140
+ // agent on grep, over-blocking strands it with no tool at all.
141
+ function isIndexedTarget(root, target) {
142
+ if (!target) return true; // no path given: a whole-workspace search is vexp's job
143
+ const s = String(target);
144
+ const low = s.toLowerCase();
145
+ for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
146
+ let rel;
147
+ try {
148
+ rel = relative(root, isAbsolute(s) ? s : join(root, s));
149
+ } catch (e) {
150
+ return false;
151
+ }
152
+ if (rel === "..") return false;
153
+ if (rel.indexOf(".." + sep) === 0) return false; // escapes the workspace
154
+ if (isAbsolute(rel)) return false; // different root or drive
155
+ const segs = rel.split(sep);
156
+ for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
157
+ return true;
158
+ }
159
+
160
+ // Where a search is aimed. grep carries the regex in its pattern arg (never a
161
+ // path), so only path and the include filter describe its target; for glob the
162
+ // pattern IS the path.
163
+ function searchTargets(tool, args) {
164
+ if (!args) return [];
165
+ const out = [];
166
+ if (args.path) out.push(args.path);
167
+ if (tool === "glob" && args.pattern) out.push(args.pattern);
168
+ if (tool === "grep" && args.include) out.push(args.include);
169
+ return out;
170
+ }
171
+
172
+ export const VexpGuard = async ({ directory, worktree }) => {
173
+ const start = worktree || directory || ".";
174
+ let vexpDir = null;
175
+ return {
176
+ "tool.execute.before": async (input, output) => {
177
+ if (!vexpDir) vexpDir = findVexpDir(start);
178
+ if (!daemonHealthy(vexpDir)) return; // no live index -> allow
179
+ if (!SEARCH_TOOLS.has(input.tool)) return; // bash and everything else: never blocked
180
+ const root = dirname(vexpDir);
181
+ const targets = searchTargets(input.tool, output && output.args);
182
+ for (const t of targets) {
183
+ if (!isIndexedTarget(root, t)) return; // vexp has no answer here -> allow
184
+ }
185
+ throw new Error(
186
+ "vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
187
+ input.tool + " - it returns pre-indexed, graph-ranked context in one call." +
188
+ " This is policy, not a transient failure: do NOT work around it with shell" +
189
+ " search or by writing a script."
190
+ );
191
+ },
192
+ };
193
+ };
194
+ `;
@@ -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
+ }