vexp-cli 2.7.0 → 3.1.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, setGuardMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, tryOnlineRefresh, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -17,6 +17,7 @@ import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnp
17
17
  import { runServe } from "./serve.js";
18
18
  import { runDoctor } from "./doctor.js";
19
19
  import { socketPathFor } from "./socket-path.js";
20
+ import { mutableOutput, askSecretOn } from "./secret-prompt.js";
20
21
  import { isTraceEnabled } from "./trace.js";
21
22
  import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
22
23
  const program = new Command();
@@ -41,6 +42,11 @@ if (isTraceEnabled()) {
41
42
  const AUTOSTART_SKIP = new Set([
42
43
  "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
43
44
  "serve", "autostart", "use", "doctor",
45
+ // Lifecycle commands: `vexp daemons` LISTS daemons and `vexp stop` ENDS
46
+ // one — spawning a daemon for the current directory first made `stop`
47
+ // followed by `daemons` bring the stopped daemon straight back
48
+ // (3.1 e2e, 2026-08).
49
+ "daemons", "stop",
44
50
  ]);
45
51
  program.hook("preAction", async (_thisCmd, actionCmd) => {
46
52
  // One-shot config migration for the multi-session fix — de-pins a legacy
@@ -455,7 +461,7 @@ program
455
461
  });
456
462
  program
457
463
  .command("search <query>")
458
- .description("Exhaustive index search: every matching node (code and docs), no top-K. Built for rename sweeps and zero-reference audits")
464
+ .description("Exhaustive index search: every symbol matching the query (code and docs) plus every line in indexed symbol bodies that references it, no top-K. Built for rename sweeps and zero-reference audits")
459
465
  .option("--substring", "Substring match (LIKE) instead of token search — partial identifiers, punctuation")
460
466
  .option("--files-only", "Print only the distinct file paths")
461
467
  .option("--json", "Machine-readable JSON with a stable shape")
@@ -633,9 +639,12 @@ program
633
639
  .option("--dry-run", "Show what would be configured without writing files")
634
640
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
635
641
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
642
+ .option("--interventions", "Install the stop gate and the edit-time coupling hint (opt-in since 2.8: each buys tokens with turns, and a turn costs the whole transcript)")
636
643
  .action(async (dir, opts) => {
637
644
  const workspaceRoot = path.resolve(dir ?? process.cwd());
638
645
  setGuardMode(opts.guardStrict ? "strict" : "off");
646
+ setInterventionMode(opts.interventions ? "on" : "off");
647
+ setInterventionMode(opts.interventions ? "on" : "off");
639
648
  console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
640
649
  // Step 1: Ensure binary
641
650
  const spinner1 = ora("Checking vexp binary...").start();
@@ -824,7 +833,15 @@ program
824
833
  // the daemon already cold-starts on the first `vexp` invocation, so this is
825
834
  // purely a convenience for post-reboot warm-up. Skip entirely when the user
826
835
  // opted out via env or when stdin is not a TTY (non-interactive install).
827
- if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
836
+ //
837
+ // A dry run must not reach this step at all: the prompt was asked even
838
+ // under --dry-run and answering Yes rewrote the Startup-folder .vbs while
839
+ // the summary still claimed nothing was written (field report, Peiyuan,
840
+ // 3.0.1 on Windows). Persistence is a write like any other.
841
+ if (opts.dryRun) {
842
+ console.log(chalk.dim(" --dry-run: would ask about login autostart (writes a Startup entry only if you accept)."));
843
+ }
844
+ else if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1") {
828
845
  // Explicit opt-out — do nothing.
829
846
  }
830
847
  else if (!process.stdin.isTTY) {
@@ -889,6 +906,7 @@ program
889
906
  .command("setup-agents [dir]")
890
907
  .description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
891
908
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
909
+ .option("--interventions", "Install the stop gate and the edit-time coupling hint (opt-in since 2.8: each buys tokens with turns, and a turn costs the whole transcript)")
892
910
  .action(async (dir, opts) => {
893
911
  setGuardMode(opts.guardStrict ? "strict" : "off");
894
912
  await runSetupAgents(dir);
@@ -1039,17 +1057,16 @@ async function runSetupInteractive(rl) {
1039
1057
  // ────────────────────────────────────────────────────
1040
1058
  program
1041
1059
  .command("activate [key]")
1042
- .description("Activate a vexp Pro/Team license key")
1060
+ .description("Activate a vexp Pro/Team license key (omit the key to enter it without it reaching shell history)")
1043
1061
  .action(async (key) => {
1044
1062
  if (!key) {
1045
1063
  console.log(chalk.cyan("Get your license key at https://vexp.dev/#pricing\n"));
1046
- const readline = await import("readline");
1047
- const rl = readline.createInterface({
1064
+ const { promptSecret } = await import("./secret-prompt.js");
1065
+ key = await promptSecret("License key: ", {
1048
1066
  input: process.stdin,
1049
1067
  output: process.stdout,
1068
+ isTTY: process.stdin.isTTY === true,
1050
1069
  });
1051
- key = await new Promise((resolve) => rl.question("License key: ", resolve));
1052
- rl.close();
1053
1070
  }
1054
1071
  try {
1055
1072
  const claims = activateLicense(key.trim());
@@ -1104,6 +1121,9 @@ program
1104
1121
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1105
1122
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1106
1123
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1124
+ if (limits.allTools) {
1125
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1126
+ }
1107
1127
  if (limits.renewsAt) {
1108
1128
  console.log(ltd
1109
1129
  ? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
@@ -1370,7 +1390,10 @@ async function interactiveMode() {
1370
1390
  }
1371
1391
  await printBanner();
1372
1392
  printMainMenu();
1373
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1393
+ // Built over a mutable stdout so `activate` can hide the key being typed:
1394
+ // this interface lives for the whole session and closing it exits.
1395
+ const outCtl = mutableOutput(process.stdout);
1396
+ const rl = readline.createInterface({ input: process.stdin, output: outCtl.stream, terminal: true });
1374
1397
  rl.on("close", () => { console.log(""); process.exit(0); });
1375
1398
  let currentMenu = "main";
1376
1399
  const prompts = {
@@ -1424,7 +1447,7 @@ async function interactiveMode() {
1424
1447
  const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
1425
1448
  const direct = allItems.find((m) => m.label === input);
1426
1449
  if (direct) {
1427
- await executeCommand(direct.label, rl);
1450
+ await executeCommand(direct.label, rl, outCtl);
1428
1451
  // After a direct command, reprint the main menu so options stay visible.
1429
1452
  printMainMenu();
1430
1453
  continue;
@@ -1453,14 +1476,14 @@ async function interactiveMode() {
1453
1476
  }
1454
1477
  // Show description before executing
1455
1478
  console.log(chalk.dim(`\n ${item.description}\n`));
1456
- await executeCommand(item.label, rl);
1479
+ await executeCommand(item.label, rl, outCtl);
1457
1480
  console.log("");
1458
1481
  // Sticky sub-menu: reprint the current sub-menu after every command so
1459
1482
  // the user always sees the available options without typing '?'.
1460
1483
  printSubMenu(titles[currentMenu], items);
1461
1484
  }
1462
1485
  }
1463
- async function executeCommand(label, rl) {
1486
+ async function executeCommand(label, rl, outCtl) {
1464
1487
  try {
1465
1488
  switch (label) {
1466
1489
  // ── Config commands ──
@@ -1742,6 +1765,9 @@ async function executeCommand(label, rl) {
1742
1765
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1743
1766
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1744
1767
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1768
+ if (limits.allTools) {
1769
+ console.log(` (the MCP tool list shows 4 by default to keep the catalog small; every tool stays callable — VEXP_ALL_TOOLS=1 lists them all)`);
1770
+ }
1745
1771
  if (limits.renewsAt) {
1746
1772
  console.log(ltd
1747
1773
  ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
@@ -1750,7 +1776,7 @@ async function executeCommand(label, rl) {
1750
1776
  break;
1751
1777
  }
1752
1778
  case "activate": {
1753
- const key = await ask(rl, chalk.cyan(" License key: "));
1779
+ const key = await askSecretOn(rl, chalk.cyan(" License key: "), outCtl, process.stdout);
1754
1780
  if (key.trim()) {
1755
1781
  try {
1756
1782
  const claims = activateLicense(key.trim());
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import * as net from "net";
5
5
  import { spawnSync } from "child_process";
6
6
  import chalk from "chalk";
7
7
  import { socketPathFor } from "./socket-path.js";
8
+ import { parseJsonc } from "./agent-config.js";
8
9
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
9
10
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
10
11
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -113,6 +114,53 @@ function isAlive(pid) {
113
114
  * the daemon watches the tree live and reconciles it against disk every five
114
115
  * minutes — and the remedy was unusable. A user spent a round trip on it.
115
116
  */
117
+ /**
118
+ * Which IDE owns a running daemon, from its executable path.
119
+ *
120
+ * A daemon started by an IDE extension runs the binary bundled INSIDE that
121
+ * extension's directory, and the extension host owns its lifecycle. Telling
122
+ * that user to run `vexp daemon-cmd restart` sends them in a circle: the kill
123
+ * lands, the same extension host respawns the same old binary, and nothing
124
+ * changes. The window has to reload so the host picks up the new extension.
125
+ * A user lost time on exactly this and worked it out himself.
126
+ *
127
+ * Matching is on the IDE's own extensions directory, which is where the
128
+ * bundled core lives (`<ide>/extensions/<publisher>.vexp-<ver>/binaries/...`).
129
+ * Remote/WSL installs use `.vscode-server`; forks use their own dot-dir.
130
+ */
131
+ export function daemonOwnerIde(exePath) {
132
+ if (!exePath)
133
+ return null;
134
+ const p = exePath.replace(/\\/g, "/").toLowerCase();
135
+ const ides = [
136
+ [/\/\.vscode-server-insiders\/extensions\//, "VS Code Insiders (Remote)"],
137
+ [/\/\.vscode-server\/extensions\//, "VS Code (Remote/WSL)"],
138
+ [/\/\.vscode-insiders\/extensions\//, "VS Code Insiders"],
139
+ [/\/\.vscode-oss\/extensions\//, "VSCodium"],
140
+ [/\/\.vscode\/extensions\//, "VS Code"],
141
+ [/\/\.cursor-server\/extensions\//, "Cursor (Remote)"],
142
+ [/\/\.cursor\/extensions\//, "Cursor"],
143
+ [/\/\.windsurf-server\/extensions\//, "Windsurf (Remote)"],
144
+ [/\/\.windsurf\/extensions\//, "Windsurf"],
145
+ [/\/\.trae\/extensions\//, "Trae"],
146
+ [/\/\.antigravity\/extensions\//, "Antigravity"],
147
+ ];
148
+ for (const [re, label] of ides) {
149
+ if (re.test(p))
150
+ return label;
151
+ }
152
+ return null;
153
+ }
154
+ /** The remedy line for a daemon that is behind the installed engine. */
155
+ export function staleDaemonRemedy(exePath) {
156
+ const ide = daemonOwnerIde(exePath);
157
+ if (ide) {
158
+ return (`this daemon belongs to ${ide} (${exePath}) — 'vexp daemon-cmd restart' will NOT fix it: ` +
159
+ `the extension host respawns the same build.\n` +
160
+ ` reload the ${ide} window instead (Command Palette → "Developer: Reload Window").`);
161
+ }
162
+ return `run 'vexp daemon-cmd restart' to upgrade it now.`;
163
+ }
116
164
  export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
117
165
  const ourHooksDir = path.join(repoRoot, ".git", "hooks");
118
166
  if (!hooksPath) {
@@ -144,6 +192,80 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
144
192
  ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
145
193
  };
146
194
  }
195
+ /**
196
+ * The size-skip verdict, from `.vexp/coverage.json` (3.1 shape), as data.
197
+ *
198
+ * Files over `max_file_size_kb` are left out of the index by the walk. Until
199
+ * 3.1 the only trace was an INFO line in the daemon log: doctor, the
200
+ * `index_status` tool and coverage.json all reported a healthy index while a
201
+ * tier-4 user was missing five hand-written Dart files — 21% of the bytes of
202
+ * his lib/ — from every impact, search and pipeline answer (field report,
203
+ * 2026-08). Raising the cap only moved the cliff, so the cliff is reported
204
+ * wherever it stands. Null when nothing was skipped for size, or when the
205
+ * file predates 3.1 and has no skip keys.
206
+ */
207
+ export function coverageVerdict(cov) {
208
+ if (!cov || typeof cov !== "object")
209
+ return null;
210
+ const c = cov;
211
+ const oversized = Number(c.skipped_oversized) || 0;
212
+ if (oversized === 0)
213
+ return null;
214
+ const cap = Number(c.max_file_size_kb) || 0;
215
+ const files = Array.isArray(c.skipped_files) ? c.skipped_files : [];
216
+ const examples = files
217
+ .filter((f) => f.reason === "oversized")
218
+ .slice(0, 5)
219
+ .map((f) => `${f.path} (${Number(f.size_kb) || 0} KB)`);
220
+ const more = oversized - examples.length;
221
+ return {
222
+ level: WARN,
223
+ message: `${oversized} file(s) over max_file_size_kb = ${cap} are NOT indexed — their symbols and callers are invisible to impact, search and run_pipeline:\n` +
224
+ examples.map((e) => ` - ${e}`).join("\n") +
225
+ (more > 0 ? `\n … +${more} more (full list: .vexp/coverage.json)` : "") +
226
+ `\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns.`,
227
+ };
228
+ }
229
+ /**
230
+ * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
231
+ *
232
+ * A Copilot user who "set up the vexp agent" and sees neither a vexp server in
233
+ * VS Code nor vexp tools in chat has, in our experience, one of three things:
234
+ * the file is not where VS Code looks (the folder open in VS Code is not the
235
+ * one that was set up), the server entry cannot start (a bare `node` that the
236
+ * GUI-launched editor cannot find on its PATH, or a bundle path an extension
237
+ * upgrade removed), or Chat is not in Agent mode — the only mode that offers
238
+ * MCP tools. doctor can prove the first two; it can only say the third.
239
+ */
240
+ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p)) {
241
+ if (!cfg || typeof cfg !== "object")
242
+ return { level: WARN, message: ".vscode/mcp.json is present but not valid JSON — VS Code will ignore every server in it; fix the syntax and re-run 'vexp setup'" };
243
+ const servers = cfg.servers;
244
+ const vexp = servers?.vexp;
245
+ if (!vexp)
246
+ return { level: WARN, message: ".vscode/mcp.json has no 'vexp' server — run: vexp setup --agents \"GitHub Copilot\"" };
247
+ const command = typeof vexp.command === "string" ? vexp.command : "";
248
+ const args = Array.isArray(vexp.args) ? vexp.args.filter((a) => typeof a === "string") : [];
249
+ const script = args.find((a) => /\.[cm]?js$/.test(a));
250
+ if (!command)
251
+ return { level: WARN, message: ".vscode/mcp.json vexp server has no 'command' — re-run 'vexp setup'" };
252
+ if (!/[\\/]/.test(command)) {
253
+ return {
254
+ level: WARN,
255
+ message: `.vscode/mcp.json starts the vexp server with a bare '${command}' — resolved through the editor's PATH, which a VS Code launched from the Dock/Start menu usually lacks (symptom: 'spawn ${command} ENOENT' in Output › MCP: vexp, no vexp tools in chat).\n` +
256
+ ` re-run 'vexp setup' — 3.1 pins the absolute node path.`,
257
+ };
258
+ }
259
+ if (!exists(command))
260
+ return { level: WARN, message: `.vscode/mcp.json vexp command does not exist: ${command} — re-run 'vexp setup' to repin it` };
261
+ if (script && !exists(script))
262
+ return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
263
+ const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
264
+ if (pinned && path.resolve(pinned).toLowerCase() !== path.resolve(wsRoot).toLowerCase()) {
265
+ return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
266
+ }
267
+ return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
268
+ }
147
269
  export async function runDoctor() {
148
270
  const home = vexpHome();
149
271
  let warns = 0;
@@ -222,10 +344,12 @@ export async function runDoctor() {
222
344
  const bundled = out.trim().split(/\s+/).pop();
223
345
  const running = st.daemon_version;
224
346
  if (bundled && running && bundled !== running) {
225
- line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version. Run 'vexp daemon-cmd restart' to upgrade it now.`);
347
+ const exe = st.daemon_exe;
348
+ line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version.\n ${staleDaemonRemedy(exe)}`);
226
349
  }
227
350
  else if (st.binary_stale === true) {
228
- line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
351
+ const exe = st.daemon_exe;
352
+ line(WARN, `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(exe)}`);
229
353
  }
230
354
  }
231
355
  catch { /* best-effort */ }
@@ -261,6 +385,16 @@ export async function runDoctor() {
261
385
  }
262
386
  }
263
387
  }
388
+ // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
389
+ // not from the daemon: the index that skipped the files may have been
390
+ // built by a daemon that is no longer running.
391
+ try {
392
+ const cov = JSON.parse(fs.readFileSync(path.join(ws.root, ".vexp", "coverage.json"), "utf-8"));
393
+ const v = coverageVerdict(cov);
394
+ if (v)
395
+ line(v.level, v.message);
396
+ }
397
+ catch { /* no coverage.json: never indexed here, or an index older than 2.7 */ }
264
398
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
265
399
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
266
400
  const regPath = path.join(home, ".vexp", "daemons.json");
@@ -385,6 +519,27 @@ export async function runDoctor() {
385
519
  catch {
386
520
  line(OK, "no ~/.claude.json");
387
521
  }
522
+ // 5a) GitHub Copilot — VS Code reads MCP servers from <folder>/.vscode/mcp.json.
523
+ console.log(chalk.bold("\nGitHub Copilot / VS Code (.vscode/mcp.json)"));
524
+ {
525
+ const mcpPath = path.join(ws.root, ".vscode", "mcp.json");
526
+ if (!fs.existsSync(mcpPath)) {
527
+ line(OK, "no .vscode/mcp.json (Copilot MCP not configured in this folder — 'vexp setup --agents \"GitHub Copilot\"' writes it)");
528
+ }
529
+ else {
530
+ let cfg = null;
531
+ try {
532
+ cfg = parseJsonc(fs.readFileSync(mcpPath, "utf-8"));
533
+ }
534
+ catch {
535
+ cfg = null;
536
+ }
537
+ const v = vsCodeMcpVerdict(cfg, ws.root);
538
+ line(v.level, v.message);
539
+ console.log(chalk.dim(" VS Code shows it under Extensions → MCP SERVERS - INSTALLED; vexp tools appear only in Copilot Chat AGENT mode (tools picker → 'MCP Server: vexp')."));
540
+ console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
541
+ }
542
+ }
388
543
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
389
544
  // just check presence. A shell-form command that word-splits on a project
390
545
  // path containing a space fails non-blocking on every call: the guard never
@@ -501,10 +656,15 @@ export async function runDoctor() {
501
656
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
502
657
  {
503
658
  const sPath = path.join(ws.root, ".claude", "settings.json");
659
+ // `optIn` hooks are not written by a default setup, so their absence is
660
+ // the expected state and must not read as a fault. The verification gate
661
+ // buys correctness with TURNS, and a turn costs the whole transcript
662
+ // (65,521 tokens measured over 13 bench sessions); `vexp setup
663
+ // --interventions` installs it for anyone who wants that trade.
504
664
  const wanted = [
505
665
  { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
506
- { event: "Stop", marker: "vexp-verify", label: "verification gate" },
507
666
  { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
667
+ { event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
508
668
  ];
509
669
  let settings = null;
510
670
  try {
@@ -533,7 +693,12 @@ export async function runDoctor() {
533
693
  .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
534
694
  .find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
535
695
  if (!hook) {
536
- line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
696
+ if (w.optIn) {
697
+ line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
698
+ }
699
+ else {
700
+ line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
701
+ }
537
702
  continue;
538
703
  }
539
704
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
@@ -393,6 +393,139 @@ VEXP_BIN="__VEXP_BIN__"
393
393
  "$VEXP_BIN" prompt-hint 2>/dev/null
394
394
  exit 0
395
395
  `;
396
+ /**
397
+ * v5: the coupling, delivered on the edit that needs it (PostToolUse).
398
+ *
399
+ * The only evidence about WHY an agent fails a multi-file task says its plan
400
+ * was incomplete, not its patch. That is the reverse-dependency question, and
401
+ * nothing in a context window answers it. This puts the answer on Edit and
402
+ * Write, which every agent uses in every session — against 4% that ever call
403
+ * a vexp tool.
404
+ *
405
+ * PostToolUse rather than PreToolUse because a deny on an edit stops real
406
+ * work, and after rather than before because the channel that reaches the
407
+ * model is `additionalContext`, verified empirically on Claude Code: it
408
+ * arrives as a system-reminder naming the hook that produced it. It adds no
409
+ * turn, which the 2.9 gate experiment showed to be worth +17% and nothing.
410
+ *
411
+ * FAIL-OPEN like every other hook: no binary, no daemon, nothing to say —
412
+ * exit 0 silent.
413
+ */
414
+ export const VEXP_EDIT_HINT_HOOK = `#!/bin/bash
415
+ # vexp-edit-hint: what else references the file you just changed. Fails open.
416
+ VEXP_BIN="__VEXP_BIN__"
417
+ [ -x "$VEXP_BIN" ] || exit 0
418
+ "$VEXP_BIN" edit-hint 2>/dev/null
419
+ exit 0
420
+ `;
421
+ export const VEXP_READ_HINT_HOOK = `#!/bin/bash
422
+ # vexp-read-hint: answer a whole-file read of a large file with its skeleton.
423
+ # The only mechanism vexp has that SUBTRACTS tokens. Fails open.
424
+ VEXP_BIN="__VEXP_BIN__"
425
+ [ -x "$VEXP_BIN" ] || exit 0
426
+ "$VEXP_BIN" read-hint 2>/dev/null
427
+ exit 0
428
+ `;
429
+ export const VEXP_BASH_CAP_HOOK = `#!/bin/bash
430
+ # vexp-bash-cap: bound the output of a shell command that has no bound of its
431
+ # own. The other door: narrowing reads alone just moved the work here. Fails
432
+ # open, and never touches a build or a test.
433
+ VEXP_BIN="__VEXP_BIN__"
434
+ [ -x "$VEXP_BIN" ] || exit 0
435
+ "$VEXP_BIN" bash-cap 2>/dev/null
436
+ exit 0
437
+ `;
438
+ /**
439
+ * opencode / Kilo compression plugin.
440
+ *
441
+ * Their `tool.execute.before` can MUTATE the arguments, not only refuse them —
442
+ * the documented example is `output.args.command = escape(output.args.command)`
443
+ * — which is the whole mechanism. Same two doors as everywhere else: a
444
+ * whole-file read becomes that file's skeleton, and a shell command with no
445
+ * bound of its own gets one.
446
+ *
447
+ * Fails open at every step. A plugin that throws takes the tool call with it,
448
+ * and a compression that breaks a session is worse than no compression.
449
+ */
450
+ export const VEXP_OPENCODE_COMPRESS = `import { spawn } from "child_process";
451
+
452
+ const BIN = "__VEXP_BIN__";
453
+
454
+ function ask(sub, payload) {
455
+ return new Promise((resolve) => {
456
+ let done = false;
457
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
458
+ let child;
459
+ try {
460
+ child = spawn(BIN, [sub], { stdio: ["pipe", "pipe", "ignore"] });
461
+ } catch { return finish(null); }
462
+ const timer = setTimeout(() => { try { child.kill(); } catch {} finish(null); }, 8000);
463
+ let out = "";
464
+ child.stdout.on("data", (d) => { out += d.toString(); });
465
+ child.on("error", () => { clearTimeout(timer); finish(null); });
466
+ child.on("close", () => {
467
+ clearTimeout(timer);
468
+ try { finish(JSON.parse(out.trim() || "null")); } catch { finish(null); }
469
+ });
470
+ try { child.stdin.end(JSON.stringify(payload)); } catch { clearTimeout(timer); finish(null); }
471
+ });
472
+ }
473
+
474
+ function patchOf(r) {
475
+ return (r && r.hookSpecificOutput && r.hookSpecificOutput.updatedInput) || null;
476
+ }
477
+
478
+ export const VexpCompress = async () => ({
479
+ "tool.execute.before": async (input, output) => {
480
+ const args = output && output.args;
481
+ if (!args) return;
482
+ try {
483
+ const tool = String(input.tool || "").toLowerCase();
484
+ if (tool === "bash" && typeof args.command === "string") {
485
+ const p = patchOf(await ask("bash-cap", {
486
+ tool_name: "Bash",
487
+ tool_input: { command: args.command },
488
+ session_id: input.sessionID || "",
489
+ }));
490
+ if (p && typeof p.command === "string") args.command = p.command;
491
+ return;
492
+ }
493
+ if (tool === "read") {
494
+ const key = args.filePath !== undefined ? "filePath"
495
+ : args.file_path !== undefined ? "file_path"
496
+ : args.path !== undefined ? "path" : null;
497
+ if (!key || typeof args[key] !== "string") return;
498
+ // An agent that already asked for a range knows what it wants.
499
+ if (args.offset !== undefined || args.limit !== undefined) return;
500
+ const p = patchOf(await ask("read-hint", {
501
+ tool_name: "Read",
502
+ tool_input: { file_path: args[key] },
503
+ session_id: input.sessionID || "",
504
+ }));
505
+ if (p && typeof p.file_path === "string") args[key] = p.file_path;
506
+ }
507
+ } catch {
508
+ // Never let compression break a tool call.
509
+ }
510
+ },
511
+ });
512
+ `;
513
+ /** Bake the binary path into the opencode/Kilo compression plugin. */
514
+ export function vexpOpencodeCompressPlugin(binaryPath) {
515
+ return VEXP_OPENCODE_COMPRESS.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
516
+ }
517
+ /** Bake the binary path into the read-hint hook script. */
518
+ export function bakeReadHintHook(binaryPath) {
519
+ return VEXP_READ_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
520
+ }
521
+ /** Bake the binary path into the bash-cap hook script. */
522
+ export function bakeBashCapHook(binaryPath) {
523
+ return VEXP_BASH_CAP_HOOK.replace("__VEXP_BIN__", binaryPath);
524
+ }
525
+ /** Bake the binary path into the edit-hint hook script. */
526
+ export function bakeEditHintHook(binaryPath) {
527
+ return VEXP_EDIT_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
528
+ }
396
529
  /** Bake the binary path into the hint hook script. */
397
530
  export function vexpHintHookScript(binaryPath) {
398
531
  return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
@@ -517,6 +650,37 @@ export const VexpHint = async ({ directory, client }) => {
517
650
  }).catch(() => resolve(""));
518
651
  });
519
652
  return {
653
+ // v5: the coupling on the edit, for opencode and Kilo.
654
+ //
655
+ // Their guard plugin uses "tool.execute.before" (verified in our own
656
+ // tests), so ".after" follows the pattern. If that event does not exist
657
+ // the handler is simply never called — inert, never harmful, which is the
658
+ // same fail-open contract as every other surface here.
659
+ "tool.execute.after": async (input, output) => {
660
+ try {
661
+ const tool = String((input && input.tool) || "");
662
+ if (!/^(edit|write|patch|multiedit)$/i.test(tool)) return;
663
+ const args = (input && input.args) || {};
664
+ const file =
665
+ args.filePath || args.file_path || args.path || (output && output.filePath);
666
+ if (!file) return;
667
+ const payload = JSON.stringify({
668
+ tool_name: "Edit",
669
+ session_id: (input && input.sessionID) || "",
670
+ tool_input: { file_path: String(file) },
671
+ });
672
+ const raw = await runVexp(["edit-hint"], { cwd: directory, input: payload, timeout: 5000 });
673
+ if (!raw) return;
674
+ const parsed = JSON.parse(raw);
675
+ const text = parsed?.hookSpecificOutput?.additionalContext;
676
+ if (!text) return;
677
+ if (output && Array.isArray(output.parts)) {
678
+ output.parts.push({ type: "text", text: String(text) });
679
+ }
680
+ } catch (e) {
681
+ /* fail open */
682
+ }
683
+ },
520
684
  "chat.message": async (input, output) => {
521
685
  try {
522
686
  const text = (output.parts || [])
@@ -540,7 +704,19 @@ export const VexpHint = async ({ directory, client }) => {
540
704
  });
541
705
  if (!out || !out.trim()) return;
542
706
  const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
543
- if (hint) output.parts.push({ type: "text", text: hint });
707
+ // Never push a bare {type,text} part: Kilo 7.4.x validates every part
708
+ // against a schema requiring id/sessionID/messageID before save, so an
709
+ // injected bare part poisons the whole user message (43/43
710
+ // InvalidDurableEvent, prompt dies in both the extension and the CLI -
711
+ // Kilo field report, 2026-08). Appending onto the user's own text part
712
+ // rides its already-valid identity on every opencode/Kilo version.
713
+ if (hint) {
714
+ const texts = (output.parts || []).filter(
715
+ (p) => p && p.type === "text" && typeof p.text === "string"
716
+ );
717
+ const target = texts[texts.length - 1];
718
+ if (target) target.text = target.text + "\\n\\n" + hint;
719
+ }
544
720
  } catch (e) { /* fail open */ }
545
721
  },
546
722
  event: async ({ event }) => {
package/dist/license.js CHANGED
@@ -22,8 +22,12 @@ const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
22
22
  const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
23
23
  // Opportunistic refresh cadence: don't hit the network more than once per 24h.
24
24
  const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
25
- // 3 second timeout on validate calls — any slower falls back silently.
26
- const VALIDATE_TIMEOUT_MS = 3000;
25
+ // 10 second timeout on validate calls — any slower falls back silently.
26
+ // Was 3s: a serverless cold start plus slow DNS (WSL resolvers routinely
27
+ // take seconds) blew that budget, so the refresh that keeps the rolling
28
+ // fresh.jwt alive quietly never landed and users fell back to the long
29
+ // JWT — or, once that lapsed, to the free tier.
30
+ const VALIDATE_TIMEOUT_MS = 10_000;
27
31
  function getLicensePath() {
28
32
  return path.join(vexpHomeDir(), ".vexp", "license.jwt");
29
33
  }
@@ -202,10 +206,10 @@ export async function tryOnlineRefresh(longJwt) {
202
206
  // Only save if the freshToken itself verifies locally
203
207
  if (verifyAndDecode(data.freshToken)) {
204
208
  saveFreshToken(data.freshToken);
205
- // The server re-issued a 30-day long token (entitlement changed:
206
- // AppSumo tier up/downgrade, or a Stripe resub migration). Overwrite
207
- // the on-disk long JWT so the new entitlement survives even fully
208
- // offline and the previous one stops working — without the user
209
+ // The server re-issued the 30-day long token (rolled forward on every
210
+ // refresh since vexp-web 3.1; also on entitlement change). Overwrite
211
+ // the on-disk long JWT so the current entitlement survives even fully
212
+ // offline and a superseded one stops working — without the user
209
213
  // re-pasting a key. Only persist if it verifies locally.
210
214
  if (data.newLongToken && verifyAndDecode(data.newLongToken)) {
211
215
  try {