vexp-cli 2.2.4 → 2.3.0

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/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, resolveAgentName, suggestAgentName } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode } 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";
@@ -533,8 +533,10 @@ program
533
533
  .option("--agents [list]", "Filter agents to configure (comma-separated). Without value: auto-detect all")
534
534
  .option("--dry-run", "Show what would be configured without writing files")
535
535
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
536
+ .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
536
537
  .action(async (dir, opts) => {
537
538
  const workspaceRoot = path.resolve(dir ?? process.cwd());
539
+ setGuardMode(opts.guardStrict ? "strict" : "off");
538
540
  console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
539
541
  // Step 1: Ensure binary
540
542
  const spinner1 = ora("Checking vexp binary...").start();
@@ -737,7 +739,9 @@ program
737
739
  program
738
740
  .command("setup-agents [dir]")
739
741
  .description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
740
- .action(async (dir) => {
742
+ .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
743
+ .action(async (dir, opts) => {
744
+ setGuardMode(opts.guardStrict ? "strict" : "off");
741
745
  await runSetupAgents(dir);
742
746
  });
743
747
  async function runSetupAgents(dir) {
package/dist/doctor.js CHANGED
@@ -48,6 +48,32 @@ function resolveWorkspace() {
48
48
  // it — so doctor probed a pipe name the daemon never binds for ~6% of Windows
49
49
  // workspaces and declared a healthy daemon down.
50
50
  const socketForWorkspace = socketPathFor;
51
+ /** One-shot daemon query over the newline-JSON socket protocol. Returns the
52
+ * `result` payload or null on any failure — doctor must never hang or throw. */
53
+ function queryDaemon(sock, tool, timeoutMs = 2500) {
54
+ return new Promise((resolve) => {
55
+ const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
56
+ let buf = "";
57
+ const t = setTimeout(() => { c.destroy(); resolve(null); }, timeoutMs);
58
+ c.on("connect", () => c.write(JSON.stringify({ id: 1, tool, params: {} }) + "\n"));
59
+ c.on("data", (d) => {
60
+ buf += d.toString();
61
+ const i = buf.indexOf("\n");
62
+ if (i !== -1) {
63
+ clearTimeout(t);
64
+ c.destroy();
65
+ try {
66
+ const parsed = JSON.parse(buf.slice(0, i));
67
+ resolve(parsed?.result ?? null);
68
+ }
69
+ catch {
70
+ resolve(null);
71
+ }
72
+ }
73
+ });
74
+ c.on("error", () => { clearTimeout(t); resolve(null); });
75
+ });
76
+ }
51
77
  function reachable(sock, timeoutMs = 600) {
52
78
  return new Promise((resolve) => {
53
79
  const c = process.platform === "win32" ? net.createConnection(sock) : net.createConnection({ path: sock });
@@ -111,6 +137,34 @@ export async function runDoctor() {
111
137
  line(live ? OK : WARN, `daemon socket ${live ? "reachable" : "NOT reachable"}: ${sock}`);
112
138
  if (!live)
113
139
  console.log(chalk.dim(` → run: vexp daemon-cmd start (or 'vexp daemon-cmd restart')`));
140
+ // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
141
+ // say "LLM enabled" while the daemon serves rule-compressed output (config
142
+ // race / stale daemon / non-LLM build); only the daemon knows what's active.
143
+ if (live) {
144
+ const st = await queryDaemon(sock, "index_status");
145
+ if (st) {
146
+ const comp = st.compressor === "llm"
147
+ ? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
148
+ : st.compressor ?? "unknown (older daemon)";
149
+ line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
150
+ if (st.llm_configured_but_inactive === true) {
151
+ line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
152
+ }
153
+ // 2.3 C1 — which agent sessions actually used vexp (a session with zero
154
+ // calls never shows up here; that absence is the diagnostic).
155
+ const sessions = Array.isArray(st.sessions) ? st.sessions : [];
156
+ if (sessions.length > 0) {
157
+ const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
158
+ line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
159
+ }
160
+ else if (Number(st.daemon_uptime_s) > 600) {
161
+ line(WARN, `no agent session has called vexp since daemon start — if an agent is working here, it is not using vexp (check its MCP config)`);
162
+ }
163
+ else {
164
+ line(OK, `no session calls yet (daemon just started)`);
165
+ }
166
+ }
167
+ }
114
168
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
115
169
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
116
170
  const regPath = path.join(home, ".vexp", "daemons.json");
package/dist/serve.js CHANGED
@@ -89,17 +89,71 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
89
89
  return false;
90
90
  }
91
91
  }
92
+ /** True when `sock` is the canonical socket for `ws` itself (unix path or the
93
+ * win32 named pipe derived from `ws`). A registry row that FAILS this is a
94
+ * ROUTING entry: a connected secondary repo mapped to its parent workspace's
95
+ * socket so lookups route correctly. */
96
+ export function isOwnerEntry(ws, sock) {
97
+ if (process.platform === "win32") {
98
+ // Named pipes carry no path; treat the row as owner unless another row
99
+ // with the same pipe proves otherwise (handled by the grouping below).
100
+ return true;
101
+ }
102
+ return path.resolve(sock) === path.resolve(path.join(ws, ".vexp", "daemon.sock"));
103
+ }
104
+ /** One (workspace, socket) spawn per SOCKET, always the socket's OWNER root.
105
+ * Field incident (2026-07-23): the registry legitimately maps a connected
106
+ * repo to its PARENT workspace's socket for routing; resurrecting that row
107
+ * verbatim spawned `daemon --workspace <child> --socket <parent sock>` — a
108
+ * cross-wired daemon serving the child's index on the parent's socket, which
109
+ * then blocked every legitimate restart of the parent (it held the bind) and
110
+ * never matched the parent's config. Routing rows must never be spawned. */
111
+ export function resurrectionPlan(reg) {
112
+ const bySock = new Map();
113
+ for (const [ws, sock] of Object.entries(reg)) {
114
+ const list = bySock.get(sock) ?? [];
115
+ list.push(ws);
116
+ bySock.set(sock, list);
117
+ }
118
+ const plan = [];
119
+ for (const [sock, roots] of bySock) {
120
+ const owner = roots.find((ws) => isOwnerEntry(ws, sock));
121
+ if (owner) {
122
+ plan.push({ ws: owner, sock });
123
+ }
124
+ else if (roots.length === 1 && !sock.endsWith(path.join(".vexp", "daemon.sock"))) {
125
+ // Long-path /tmp-fallback socket (lives OUTSIDE any repo): a single
126
+ // registrant is unambiguous, keep prior behavior. A canonical
127
+ // in-repo socket that is NOT under the registrant is a routing row
128
+ // whose owner vanished from the registry — never cross-spawn it.
129
+ plan.push({ ws: roots[0], sock });
130
+ }
131
+ else {
132
+ appendLog(`registry: no owner for socket ${sock} (roots: ${roots.join(", ")}) — skipping resurrect`);
133
+ }
134
+ }
135
+ return plan;
136
+ }
92
137
  async function resurrectAll() {
93
138
  const reg = readRegistry();
94
139
  const live = {};
140
+ // Prune rows whose workspace vanished, but keep routing rows alive in the
141
+ // registry — they are data, not spawn instructions.
142
+ const pruned = {};
95
143
  for (const [ws, sock] of Object.entries(reg)) {
96
144
  const manifest = path.join(ws, ".vexp", "manifest.json");
97
145
  if (!fs.existsSync(manifest)) {
98
146
  appendLog(`registry prune: ${ws} (no manifest)`);
99
147
  continue;
100
148
  }
101
- const ok = await resurrectDaemon(ws, sock);
102
- if (ok)
149
+ pruned[ws] = sock;
150
+ }
151
+ const resurrected = new Map();
152
+ for (const { ws, sock } of resurrectionPlan(pruned)) {
153
+ resurrected.set(sock, await resurrectDaemon(ws, sock));
154
+ }
155
+ for (const [ws, sock] of Object.entries(pruned)) {
156
+ if (resurrected.get(sock))
103
157
  live[ws] = sock;
104
158
  }
105
159
  writeRegistry(live);