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/agent-config.js +308 -20
- package/dist/cli.js +28 -10
- package/dist/doctor.js +5 -14
- package/dist/hook-template.js +141 -0
- package/dist/socket-path.js +40 -0
- package/mcp/mcp-server.cjs +48 -43
- package/package.json +6 -6
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 });
|
package/dist/hook-template.js
CHANGED
|
@@ -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
|
+
}
|