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.
@@ -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,
@@ -353,6 +376,60 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
353
376
  export function getAgentList() {
354
377
  return [...AGENT_DETECTORS];
355
378
  }
379
+ /** Fold a name to its comparable form: case, spaces, dots and dashes carry no
380
+ * meaning here ("claude-code", "Claude Code" and "claudecode" are one agent). */
381
+ function foldAgentName(s) {
382
+ return s.toLowerCase().replace(/[^a-z0-9]/g, "");
383
+ }
384
+ /**
385
+ * Canonicalize a name given to `--agents`, or null if it names no agent.
386
+ *
387
+ * `configureSelectedAgents` matches with `Array.includes` on the exact string,
388
+ * so "opencode" silently configured nothing while "Opencode" worked — nobody
389
+ * can be expected to know the capitalization, and the failure was invisible.
390
+ */
391
+ export function resolveAgentName(input) {
392
+ const want = foldAgentName(input);
393
+ if (!want)
394
+ return null;
395
+ return AGENT_DETECTORS.find((d) => foldAgentName(d.agent) === want)?.agent ?? null;
396
+ }
397
+ /** Levenshtein distance, for "did you mean" on an unknown --agents name. */
398
+ function editDistance(a, b) {
399
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
400
+ const cur = new Array(b.length + 1).fill(0);
401
+ for (let i = 1; i <= a.length; i++) {
402
+ cur[0] = i;
403
+ for (let j = 1; j <= b.length; j++) {
404
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
405
+ }
406
+ prev.splice(0, prev.length, ...cur);
407
+ }
408
+ return prev[b.length];
409
+ }
410
+ /**
411
+ * The known agent an unknown name most likely meant, or null when nothing is
412
+ * close enough to be worth suggesting. Substring hits win (a bare "copilot"
413
+ * means "GitHub Copilot"); otherwise allow a couple of typos.
414
+ */
415
+ export function suggestAgentName(input) {
416
+ const want = foldAgentName(input);
417
+ if (!want)
418
+ return null;
419
+ const contained = AGENT_DETECTORS.find((d) => {
420
+ const f = foldAgentName(d.agent);
421
+ return f.includes(want) || want.includes(f);
422
+ });
423
+ if (contained)
424
+ return contained.agent;
425
+ let best = null;
426
+ for (const d of AGENT_DETECTORS) {
427
+ const dist = editDistance(want, foldAgentName(d.agent));
428
+ if (!best || dist < best.d)
429
+ best = { name: d.agent, d: dist };
430
+ }
431
+ return best && best.d <= 3 ? best.name : null;
432
+ }
356
433
  /**
357
434
  * Configure specific agents selected by the user.
358
435
  * Unlike configureAgents which auto-detects, this forces configuration
@@ -445,6 +522,9 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
445
522
  if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
446
523
  mcpConfigs.push(".zed/settings.json");
447
524
  }
525
+ if (detector.agent === "Opencode") {
526
+ installOpencodePlugin(workspaceRoot);
527
+ }
448
528
  results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
449
529
  }
450
530
  return { agents: results, mcpConfigs };
@@ -537,6 +617,65 @@ export function readJsonConfigSafe(filePath) {
537
617
  return { data: {}, ok: false, existed: true };
538
618
  }
539
619
  /** Copy an existing config to <file>.vexp-bak before overwriting it. */
620
+ /**
621
+ * True when the vexp entry already in a config still resolves to something
622
+ * runnable — even if it points at a DIFFERENT vexp install than ours.
623
+ *
624
+ * Mirrors `vexpEntryStillResolves` in
625
+ * packages/vexp-vscode/src/providers/agent-auto-config.ts; duplicated because
626
+ * the CLI cannot depend on the extension at runtime.
627
+ *
628
+ * `mcp-server.cjs` is a standalone script that reaches the daemon over its
629
+ * socket; it does not care which install it was launched from. So an entry
630
+ * naming another install is not stale, it is just someone else's path — and
631
+ * rewriting it starts a fight the user sees as "my MCP config gets
632
+ * overwritten every time I open a different IDE". Every VS Code fork carries
633
+ * the extension and writes these same files with its own absolute extension
634
+ * path, while the CLI writes `command: "node"` plus its npm-global path — a
635
+ * third shape — so `vexp setup` and any IDE activation also undid each other.
636
+ * The old value-equality check could never settle it: a different path is
637
+ * never identical, so whoever ran last won.
638
+ *
639
+ * Staleness is handled by the check rather than by rewriting: editors reap the
640
+ * previous extension directory on upgrade (and the path embeds the version),
641
+ * so an entry left by an uninstalled build fails here and is rewritten.
642
+ *
643
+ * Accepts every shape we emit:
644
+ * {command: "node", args: ["…/mcp-server.cjs"]} most JSON configs
645
+ * {command: "…/vexp-core", args: ["mcp"]} binary-subcommand
646
+ * {command: {path, args}} Zed
647
+ * {command: ["node", "…/mcp-server.cjs"]} Kilo Code
648
+ */
649
+ function vexpEntryStillResolves(entry) {
650
+ if (!entry || typeof entry !== "object")
651
+ return false;
652
+ const e = entry;
653
+ let command;
654
+ let rawArgs;
655
+ if (Array.isArray(e.command)) {
656
+ command = e.command[0];
657
+ rawArgs = e.command.slice(1);
658
+ }
659
+ else if (e.command !== null && typeof e.command === "object") {
660
+ const nested = e.command;
661
+ command = nested.path;
662
+ rawArgs = nested.args;
663
+ }
664
+ else {
665
+ command = e.command;
666
+ rawArgs = e.args;
667
+ }
668
+ if (typeof command !== "string" || command.length === 0)
669
+ return false;
670
+ // An interpreter pinned by absolute path must still exist. A bare "node" is
671
+ // resolved via PATH at spawn time, so there is nothing to verify here.
672
+ if (path.isAbsolute(command) && !fs.existsSync(command))
673
+ return false;
674
+ // The script it runs, when the entry names one. `args: ["mcp"]` names a
675
+ // subcommand of the binary in `command`, already checked above.
676
+ const script = (Array.isArray(rawArgs) ? rawArgs : []).find((a) => typeof a === "string" && path.isAbsolute(a) && /\.[cm]?js$/.test(a));
677
+ return script === undefined || fs.existsSync(script);
678
+ }
540
679
  function backupConfig(filePath) {
541
680
  try {
542
681
  if (fs.existsSync(filePath))
@@ -685,10 +824,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
685
824
  const currentCmd = previousVexp?.["command"];
686
825
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
687
826
  const prevEnv = previousVexp?.env;
827
+ const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
688
828
  const identical = currentCmd === targetCmd &&
689
829
  prevArgs !== undefined &&
690
830
  JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
691
- JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
831
+ envMatches;
692
832
  // Legacy cleanup: remove any vexp/vexp-* keys; if the canonical 'vexp'
693
833
  // entry points at a command path that no longer exists on disk, drop it
694
834
  // too so we don't keep a stale pointer.
@@ -697,6 +837,11 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
697
837
  if (identical && removed.length === 0) {
698
838
  return false; // Already up to date and no legacy to clean
699
839
  }
840
+ // Another vexp install already left a working entry here — don't fight it.
841
+ // Only the install path may differ: `envMatches` still gates on the
842
+ // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
843
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
844
+ return false;
700
845
  const servers = existing.mcpServers ?? {};
701
846
  servers["vexp"] = {
702
847
  command: targetCmd,
@@ -765,6 +910,34 @@ function tomlString(value) {
765
910
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
766
911
  }
767
912
  /** Extract the existing canonical [mcp_servers.vexp] block (incl. subsections). */
913
+ /** Inverse of `tomlString` for the two forms it emits: a literal `'…'` (no
914
+ * escapes) or a basic `"…"` with `\\` / `\"` escaped. */
915
+ function parseTomlString(raw) {
916
+ const literal = /^'([^']*)'$/.exec(raw);
917
+ if (literal)
918
+ return literal[1];
919
+ const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
920
+ if (basic)
921
+ return basic[1].replace(/\\(["\\])/g, "$1");
922
+ return undefined;
923
+ }
924
+ /**
925
+ * The binary an existing managed *direct* section points at, when it is still
926
+ * on disk. Undefined for an http section, a malformed one, or one naming a
927
+ * binary that is gone — each of which must be rebuilt from our own path.
928
+ *
929
+ * Mirrors `codexDirectBinary` in
930
+ * packages/vexp-vscode/src/providers/agent-auto-config.ts.
931
+ */
932
+ function codexDirectBinary(section) {
933
+ const m = /^\s*command\s*=\s*(.+?)\s*$/m.exec(section);
934
+ if (!m)
935
+ return undefined;
936
+ const cmd = parseTomlString(m[1]);
937
+ if (!cmd || !path.isAbsolute(cmd) || !fs.existsSync(cmd))
938
+ return undefined;
939
+ return cmd;
940
+ }
768
941
  function extractCodexVexpSection(content) {
769
942
  const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
770
943
  return m ? m.join("\n") : "";
@@ -839,7 +1012,7 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
839
1012
  // stanza Codex can cache → "url is not supported for stdio". Pass the binary only
840
1013
  // when it exists; buildCodexSection(direct, undefined) returns null → we then
841
1014
  // PRESERVE the existing managed section instead of clobbering it with a url.
842
- const coreBinaryPath = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
1015
+ const ourCoreBinary = binaryPath && fs.existsSync(binaryPath) ? binaryPath : undefined;
843
1016
  let content = "";
844
1017
  if (fs.existsSync(configPath))
845
1018
  content = fs.readFileSync(configPath, "utf-8");
@@ -861,6 +1034,15 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
861
1034
  }
862
1035
  return false;
863
1036
  }
1037
+ // Step 2b: adopt the binary a previous install already pinned here, when it
1038
+ // still exists. Our own sections are not "user-managed", so `vexp setup` and
1039
+ // every IDE activation rewrote this file with their own binary path — and the
1040
+ // file is machine-global while Codex is detected by AGENTS.md, so it churned
1041
+ // for every project at once. Adopting rather than skipping is what keeps this
1042
+ // safe: the binary is interchangeable, but the workspace pin below is not and
1043
+ // must still follow the project being configured. See the twin comment in
1044
+ // vexp-vscode/src/providers/agent-auto-config.ts.
1045
+ const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
864
1046
  const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
865
1047
  if (!newSection) {
866
1048
  // direct requested but binary missing → preserve the existing section (only
@@ -898,14 +1080,22 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
898
1080
  const beforeServers = existing.servers;
899
1081
  const previousVexp = beforeServers?.["vexp"];
900
1082
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
1083
+ const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
901
1084
  const identical = previousVexp?.["command"] === targetCmd &&
902
1085
  prevArgs !== undefined &&
903
1086
  JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
904
- JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
1087
+ envMatches;
905
1088
  const removed = stripLegacyVexpEntries(existing, "servers");
906
1089
  logRemoval(removed);
907
1090
  if (identical && removed.length === 0)
908
1091
  return false;
1092
+ // Another vexp install already left a working entry here — don't fight it.
1093
+ // This file is the worst offender: every VS Code fork reaches it through the
1094
+ // Copilot branch, which is gated only on `.github/` existing. Only the
1095
+ // install path may differ — `envMatches` still gates on the VEXP_WORKSPACE
1096
+ // pin, so a moved or renamed project is repinned normally.
1097
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1098
+ return false;
909
1099
  const servers = existing.servers ?? {};
910
1100
  servers["vexp"] = {
911
1101
  type: "stdio",
@@ -953,13 +1143,20 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
953
1143
  const env = { VEXP_WORKSPACE: workspaceRoot };
954
1144
  const mcp = cfg.mcp ?? {};
955
1145
  const prev = mcp["vexp"];
1146
+ const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
956
1147
  const identical = prev?.["type"] === "local" &&
957
1148
  prev?.["enabled"] === true &&
958
1149
  Array.isArray(prev?.["command"]) &&
959
1150
  JSON.stringify(prev["command"]) === JSON.stringify(command) &&
960
- JSON.stringify(prev["env"]) === JSON.stringify(env);
1151
+ envMatches;
961
1152
  if (identical)
962
1153
  return null;
1154
+ // Another vexp install already left a working entry here — don't fight it.
1155
+ // Kilo's shape is `command: ["node", "<script>"]`. Only the install path may
1156
+ // differ: `envMatches` still gates on the VEXP_WORKSPACE pin, and `enabled`
1157
+ // must be true or the user turned it off deliberately.
1158
+ if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev))
1159
+ return null;
963
1160
  mcp["vexp"] = { type: "local", command, env, enabled: true };
964
1161
  cfg.mcp = mcp;
965
1162
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -982,14 +1179,21 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
982
1179
  const beforeCs = settings.context_servers;
983
1180
  const previousVexp = beforeCs?.["vexp"];
984
1181
  const curCmd = previousVexp?.["command"];
1182
+ // Zed nests env inside `command`, unlike every other writer.
1183
+ const envMatches = JSON.stringify(curCmd?.["env"]) === JSON.stringify(targetEnv);
985
1184
  const identical = curCmd?.["path"] === targetCmd &&
986
1185
  Array.isArray(curCmd?.["args"]) &&
987
1186
  JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
988
- JSON.stringify(curCmd["env"]) === JSON.stringify(targetEnv);
1187
+ envMatches;
989
1188
  const removed = stripLegacyVexpEntries(settings, "context_servers");
990
1189
  logRemoval(removed);
991
1190
  if (identical && removed.length === 0)
992
1191
  return false;
1192
+ // Another vexp install already left a working entry here — don't fight it.
1193
+ // Only the install path may differ: `envMatches` still gates on the
1194
+ // VEXP_WORKSPACE pin, so a moved or renamed project is repinned normally.
1195
+ if (removed.length === 0 && envMatches && vexpEntryStillResolves(previousVexp))
1196
+ return false;
993
1197
  const cs = settings.context_servers ?? {};
994
1198
  cs["vexp"] = {
995
1199
  command: {
@@ -1050,6 +1254,13 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
1050
1254
  logRemoval(removed);
1051
1255
  if (identical && removed.length === 0)
1052
1256
  return false;
1257
+ // Another vexp install already left a working entry here — don't fight it.
1258
+ // `prevEnv === undefined` is load-bearing, not a nicety: an entry still
1259
+ // pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
1260
+ // this file applies to EVERY project and the pin forces all parallel
1261
+ // sessions onto one daemon (the migration described above).
1262
+ if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing))
1263
+ return false;
1053
1264
  const servers = config.mcpServers ?? {};
1054
1265
  servers["vexp"] = {
1055
1266
  command: desiredCommand,
@@ -1180,6 +1391,59 @@ export function installClaudeCodeHook(workspaceRoot) {
1180
1391
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1181
1392
  return existed ? "updated" : "created";
1182
1393
  }
1394
+ // ---------------------------------------------------------------------------
1395
+ // opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
1396
+ // is healthy. opencode has no PreToolUse hook, but auto-loads plugins from
1397
+ // .opencode/plugins/ and lets a `tool.execute.before` hook abort a tool by
1398
+ // throwing — the same enforcement the Claude Code guard hook provides.
1399
+ // ---------------------------------------------------------------------------
1400
+ /**
1401
+ * Install the vexp-guard plugin for opencode.
1402
+ * Writes .opencode/plugins/vexp-guard.js (opencode auto-loads the directory at
1403
+ * startup — no config-file merge required). Returns the action taken, or null
1404
+ * when the file is already byte-identical.
1405
+ */
1406
+ export function installOpencodePlugin(workspaceRoot) {
1407
+ const pluginDir = path.join(workspaceRoot, ".opencode", "plugins");
1408
+ const pluginPath = path.join(pluginDir, "vexp-guard.js");
1409
+ fs.mkdirSync(pluginDir, { recursive: true });
1410
+ const existed = fs.existsSync(pluginPath);
1411
+ if (existed) {
1412
+ const current = fs.readFileSync(pluginPath, "utf-8");
1413
+ if (current === VEXP_OPENCODE_GUARD)
1414
+ return null; // identical - skip
1415
+ // Content differs: either an older vexp guard, or a copy the user tuned by
1416
+ // hand. We refresh either way — refusing to touch a hand-edited plugin would
1417
+ // freeze that user on a stale guard forever — but their version stays
1418
+ // recoverable at vexp-guard.js.vexp-bak instead of being silently discarded.
1419
+ backupConfig(pluginPath);
1420
+ }
1421
+ fs.writeFileSync(pluginPath, VEXP_OPENCODE_GUARD, "utf-8");
1422
+ return existed ? "updated" : "created";
1423
+ }
1424
+ /**
1425
+ * The single highest-leverage instruction we ship, and for a long time the one
1426
+ * most agents never saw.
1427
+ *
1428
+ * vexp retrieval is identifier/path-first: FTS supplies the candidate pool and
1429
+ * the semantic layer only RE-RANKS it, so a symbol that no query token reaches
1430
+ * never enters the running at all. A task written as prose ("why does the proxy
1431
+ * fail?") therefore ranks far worse than the same task anchored on the symbols
1432
+ * and paths it is actually about — the difference between a useful pivot set and
1433
+ * "the vexp result wasn't relevant".
1434
+ *
1435
+ * This shipped only in the Claude Code and Cursor templates, while the AGENTS.md
1436
+ * example (`"task": "fix auth bug"`) actively modelled the weak mode — so two
1437
+ * independent field reports ("the index seems not very useful") came from users
1438
+ * whose instructions never told them the one thing that makes retrieval land.
1439
+ * Shared by every prose template so the wording cannot drift again. ASCII only
1440
+ * (these land in files that get read on Windows).
1441
+ */
1442
+ const QUERY_SHAPE = `### Query shape (do this)
1443
+ - Anchor the task on real identifiers (ClassName, functionName) or file paths:
1444
+ \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1445
+ - A pure natural-language question ("why does login fail?") falls back to text
1446
+ ranking and is much less reliable - name the symbols/files you want, not the question.`;
1183
1447
  function generateAgentConfig(template, vars) {
1184
1448
  switch (template) {
1185
1449
  case "claude-code":
@@ -1221,6 +1485,12 @@ uses fewer tokens than manual searching. Prefer \`get_skeleton\` over Read to
1221
1485
  inspect files (detail: minimal/standard/detailed, 70-90% token savings).
1222
1486
  Only use Read when you need exact raw content to edit a specific line.
1223
1487
 
1488
+ The hook denies Grep/Glob while the daemon is up. That is policy, not a transient
1489
+ failure: call \`run_pipeline\` instead - do NOT work around it by shelling out or
1490
+ writing a script. vexp only covers indexed source inside the workspace: for runtime
1491
+ logs, build output (dist/, .vite/, node_modules/) or files outside the repo it has
1492
+ no answer - use Bash/Read there, those are never blocked.
1493
+
1224
1494
  ### Primary Tool
1225
1495
  - \`run_pipeline\` - **USE THIS FOR EVERYTHING**. Single call that runs
1226
1496
  capsule + impact + memory server-side. Returns compressed results.
@@ -1236,6 +1506,8 @@ Only use Read when you need exact raw content to edit a specific line.
1236
1506
  - \`index_status\` - indexing status and health check
1237
1507
  - \`expand_vexp_ref\` - expand V-REF hash placeholders in v2 compact output
1238
1508
 
1509
+ ${QUERY_SHAPE}
1510
+
1239
1511
  ### Workflow
1240
1512
  1. \`run_pipeline("your task")\` - ALWAYS FIRST. Returns pivots + impact + memories in 1 call
1241
1513
  2. Need more detail on a file? Use \`get_skeleton({ files: [...], detail: "detailed" })\` - avoid Read unless editing
@@ -1256,9 +1528,6 @@ Only use Read when you need exact raw content to edit a specific line.
1256
1528
  - **Session Memory**: auto-captures observations; memories auto-surfaced in results
1257
1529
  - **LSP Bridge**: VS Code captures type-resolved call edges
1258
1530
  - **Change Coupling**: co-changed files included as related context
1259
- - **Query tips**: include real identifiers (ClassName, function_name) or file paths
1260
- in the task for precise matches - pure natural-language phrasing falls back to
1261
- text ranking and is less reliable
1262
1531
 
1263
1532
  ### Advanced Parameters
1264
1533
  - \`preset: "debug"\` - forces debug mode (capsule+tests+impact+memory)
@@ -1289,19 +1558,19 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1289
1558
 
1290
1559
  ### Available MCP tools
1291
1560
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1292
- Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix auth bug" })\`
1561
+ Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1293
1562
  - \`get_skeleton\` - compact file structure
1294
1563
  - \`index_status\` - indexing status
1295
1564
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1296
1565
 
1566
+ ${QUERY_SHAPE}
1567
+
1297
1568
  ### Agentic search
1298
1569
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1299
1570
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
1300
1571
  rather than letting them search the codebase independently
1301
1572
 
1302
1573
  ### Tips
1303
- - Include real identifiers (class/function names) or file paths in the task - literal
1304
- matches rank best; pure natural-language phrasing falls back to text ranking
1305
1574
  - Add \`include_tests: true\` when debugging
1306
1575
  - Use \`preset: "refactor"\` for deep impact analysis
1307
1576
 
@@ -1329,11 +1598,13 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1329
1598
 
1330
1599
  ### Available MCP tools
1331
1600
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1332
- Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix auth bug" })\`
1601
+ Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1333
1602
  - \`get_skeleton\` - compact file structure
1334
1603
  - \`index_status\` - indexing status
1335
1604
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1336
1605
 
1606
+ ${QUERY_SHAPE}
1607
+
1337
1608
  ### Agentic search
1338
1609
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1339
1610
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
@@ -1378,11 +1649,13 @@ When working on this codebase:
1378
1649
 
1379
1650
  ### Available MCP tools
1380
1651
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1381
- Example: \`run_pipeline({ "task": "fix auth bug" })\`
1652
+ Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1382
1653
  - \`get_skeleton\` - token-efficient file structure
1383
1654
  - \`index_status\` - indexing status
1384
1655
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1385
1656
 
1657
+ ${QUERY_SHAPE}
1658
+
1386
1659
  ### Agentic search
1387
1660
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1388
1661
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
@@ -1408,11 +1681,13 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1408
1681
 
1409
1682
  ### Available MCP tools
1410
1683
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1411
- Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix auth bug" })\`
1684
+ Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1412
1685
  - \`get_skeleton\` - compact file structure
1413
1686
  - \`index_status\` - indexing status
1414
1687
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1415
1688
 
1689
+ ${QUERY_SHAPE}
1690
+
1416
1691
  ### Agentic search
1417
1692
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1418
1693
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
@@ -1438,11 +1713,13 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1438
1713
 
1439
1714
  ### Available MCP tools
1440
1715
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1441
- Example: \`run_pipeline({ "task": "fix auth bug" })\`
1716
+ Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1442
1717
  - \`get_skeleton\` - compact file structure
1443
1718
  - \`index_status\` - indexing status
1444
1719
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1445
1720
 
1721
+ ${QUERY_SHAPE}
1722
+
1446
1723
  ### Agentic search
1447
1724
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1448
1725
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
@@ -1468,13 +1745,20 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1468
1745
 
1469
1746
  ### Available MCP tools
1470
1747
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1471
- Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix auth bug" })\`
1748
+ Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1472
1749
  - \`get_skeleton\` - compact file structure
1473
1750
  - \`index_status\` - indexing status
1474
1751
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1475
1752
 
1753
+ ${QUERY_SHAPE}
1754
+
1476
1755
  ### Agentic search
1477
1756
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1757
+ - If a search tool is denied, that is policy, not a transient failure: call \`run_pipeline\`
1758
+ instead. Do NOT work around it with shell search or by writing a script.
1759
+ - vexp only covers indexed source inside the workspace. For runtime logs, build output
1760
+ (dist/, .vite/, node_modules/) or files outside the repo it has no answer - use your
1761
+ normal tools there; those searches are never blocked.
1478
1762
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
1479
1763
  rather than letting them search the codebase independently
1480
1764
 
@@ -1498,11 +1782,13 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1498
1782
 
1499
1783
  ## Available vexp tools
1500
1784
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1501
- Example: \`run_pipeline({ "task": "fix auth bug" })\`
1785
+ Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1502
1786
  - \`get_skeleton\` - compact file structure
1503
1787
  - \`index_status\` - indexing status
1504
1788
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1505
1789
 
1790
+ ${QUERY_SHAPE}
1791
+
1506
1792
  ## Agentic search
1507
1793
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1508
1794
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
@@ -1528,11 +1814,13 @@ vexp returns pre-indexed, graph-ranked context in a single call.
1528
1814
 
1529
1815
  ### Available MCP tools
1530
1816
  - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1531
- Example: \`run_pipeline({ "task": "fix auth bug" })\`
1817
+ Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1532
1818
  - \`get_skeleton\` - compact file structure
1533
1819
  - \`index_status\` - indexing status
1534
1820
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
1535
1821
 
1822
+ ${QUERY_SHAPE}
1823
+
1536
1824
  ### Agentic search
1537
1825
  - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1538
1826
  - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from "fs";
8
8
  import * as net from "net";
9
9
  import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
- import { detectAgents, getAgentList, configureSelectedAgents } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -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 {
@@ -552,8 +555,24 @@ program
552
555
  const detectedNames = new Set(detected.map((a) => a.agent));
553
556
  let selectedNames;
554
557
  if (typeof opts.agents === "string") {
555
- // Explicit list from --agents flag
556
- selectedNames = opts.agents.split(",").map((s) => s.trim());
558
+ // Explicit list from --agents flag. Resolve every name BEFORE writing
559
+ // anything: matching is exact downstream, so an unrecognised name used
560
+ // to configure nothing while setup still reported success — the worst
561
+ // possible outcome for the flag whose whole point is automation.
562
+ const requested = opts.agents.split(",").map((s) => s.trim()).filter(Boolean);
563
+ const unknown = requested.filter((n) => resolveAgentName(n) === null);
564
+ if (unknown.length > 0 || requested.length === 0) {
565
+ console.error(chalk.red(`\n✖ Unknown agent: ${unknown.map((u) => `"${u}"`).join(", ") || "(empty --agents list)"}`));
566
+ for (const u of unknown) {
567
+ const hint = suggestAgentName(u);
568
+ if (hint)
569
+ console.error(chalk.yellow(` Did you mean: ${hint}?`));
570
+ }
571
+ console.error(chalk.dim(` Valid agents: ${allAgents.map((a) => a.agent).join(", ")}`));
572
+ console.error(chalk.dim(" Names are matched ignoring case and punctuation.\n"));
573
+ process.exit(1);
574
+ }
575
+ selectedNames = requested.map((n) => resolveAgentName(n));
557
576
  console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
558
577
  }
559
578
  else {
@@ -1043,8 +1062,7 @@ async function printBanner() {
1043
1062
  console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
1044
1063
  }
1045
1064
  else {
1046
- const socketPath = path.join(ws, ".vexp", "daemon.sock");
1047
- const socketAlive = await isSocketAlive(socketPath);
1065
+ const socketAlive = await isSocketAlive(socketPathFor(ws));
1048
1066
  if (socketAlive) {
1049
1067
  // Display PID + uptime when we can read them; otherwise a plain "running".
1050
1068
  const { pid } = isDaemonPidAlive(ws);