vexp-cli 2.6.3 → 3.0.1

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();
@@ -633,9 +634,12 @@ program
633
634
  .option("--dry-run", "Show what would be configured without writing files")
634
635
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
635
636
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
637
+ .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
638
  .action(async (dir, opts) => {
637
639
  const workspaceRoot = path.resolve(dir ?? process.cwd());
638
640
  setGuardMode(opts.guardStrict ? "strict" : "off");
641
+ setInterventionMode(opts.interventions ? "on" : "off");
642
+ setInterventionMode(opts.interventions ? "on" : "off");
639
643
  console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
640
644
  // Step 1: Ensure binary
641
645
  const spinner1 = ora("Checking vexp binary...").start();
@@ -889,6 +893,7 @@ program
889
893
  .command("setup-agents [dir]")
890
894
  .description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
891
895
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
896
+ .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
897
  .action(async (dir, opts) => {
893
898
  setGuardMode(opts.guardStrict ? "strict" : "off");
894
899
  await runSetupAgents(dir);
@@ -1039,17 +1044,16 @@ async function runSetupInteractive(rl) {
1039
1044
  // ────────────────────────────────────────────────────
1040
1045
  program
1041
1046
  .command("activate [key]")
1042
- .description("Activate a vexp Pro/Team license key")
1047
+ .description("Activate a vexp Pro/Team license key (omit the key to enter it without it reaching shell history)")
1043
1048
  .action(async (key) => {
1044
1049
  if (!key) {
1045
1050
  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({
1051
+ const { promptSecret } = await import("./secret-prompt.js");
1052
+ key = await promptSecret("License key: ", {
1048
1053
  input: process.stdin,
1049
1054
  output: process.stdout,
1055
+ isTTY: process.stdin.isTTY === true,
1050
1056
  });
1051
- key = await new Promise((resolve) => rl.question("License key: ", resolve));
1052
- rl.close();
1053
1057
  }
1054
1058
  try {
1055
1059
  const claims = activateLicense(key.trim());
@@ -1370,7 +1374,10 @@ async function interactiveMode() {
1370
1374
  }
1371
1375
  await printBanner();
1372
1376
  printMainMenu();
1373
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1377
+ // Built over a mutable stdout so `activate` can hide the key being typed:
1378
+ // this interface lives for the whole session and closing it exits.
1379
+ const outCtl = mutableOutput(process.stdout);
1380
+ const rl = readline.createInterface({ input: process.stdin, output: outCtl.stream, terminal: true });
1374
1381
  rl.on("close", () => { console.log(""); process.exit(0); });
1375
1382
  let currentMenu = "main";
1376
1383
  const prompts = {
@@ -1424,7 +1431,7 @@ async function interactiveMode() {
1424
1431
  const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
1425
1432
  const direct = allItems.find((m) => m.label === input);
1426
1433
  if (direct) {
1427
- await executeCommand(direct.label, rl);
1434
+ await executeCommand(direct.label, rl, outCtl);
1428
1435
  // After a direct command, reprint the main menu so options stay visible.
1429
1436
  printMainMenu();
1430
1437
  continue;
@@ -1453,14 +1460,14 @@ async function interactiveMode() {
1453
1460
  }
1454
1461
  // Show description before executing
1455
1462
  console.log(chalk.dim(`\n ${item.description}\n`));
1456
- await executeCommand(item.label, rl);
1463
+ await executeCommand(item.label, rl, outCtl);
1457
1464
  console.log("");
1458
1465
  // Sticky sub-menu: reprint the current sub-menu after every command so
1459
1466
  // the user always sees the available options without typing '?'.
1460
1467
  printSubMenu(titles[currentMenu], items);
1461
1468
  }
1462
1469
  }
1463
- async function executeCommand(label, rl) {
1470
+ async function executeCommand(label, rl, outCtl) {
1464
1471
  try {
1465
1472
  switch (label) {
1466
1473
  // ── Config commands ──
@@ -1750,7 +1757,7 @@ async function executeCommand(label, rl) {
1750
1757
  break;
1751
1758
  }
1752
1759
  case "activate": {
1753
- const key = await ask(rl, chalk.cyan(" License key: "));
1760
+ const key = await askSecretOn(rl, chalk.cyan(" License key: "), outCtl, process.stdout);
1754
1761
  if (key.trim()) {
1755
1762
  try {
1756
1763
  const claims = activateLicense(key.trim());
package/dist/doctor.js CHANGED
@@ -103,6 +103,94 @@ function isAlive(pid) {
103
103
  return false;
104
104
  }
105
105
  }
106
+ /**
107
+ * The git-hooks verdict, as data.
108
+ *
109
+ * Extracted because it was wrong in a way no test could catch: an overriding
110
+ * `core.hooksPath` was reported as a FAILURE claiming "the index does not
111
+ * refresh on commit/merge/checkout", and the remedy offered was to edit a
112
+ * file that moon, husky and lefthook all regenerate. The claim is untrue —
113
+ * the daemon watches the tree live and reconciles it against disk every five
114
+ * minutes — and the remedy was unusable. A user spent a round trip on it.
115
+ */
116
+ /**
117
+ * Which IDE owns a running daemon, from its executable path.
118
+ *
119
+ * A daemon started by an IDE extension runs the binary bundled INSIDE that
120
+ * extension's directory, and the extension host owns its lifecycle. Telling
121
+ * that user to run `vexp daemon-cmd restart` sends them in a circle: the kill
122
+ * lands, the same extension host respawns the same old binary, and nothing
123
+ * changes. The window has to reload so the host picks up the new extension.
124
+ * A user lost time on exactly this and worked it out himself.
125
+ *
126
+ * Matching is on the IDE's own extensions directory, which is where the
127
+ * bundled core lives (`<ide>/extensions/<publisher>.vexp-<ver>/binaries/...`).
128
+ * Remote/WSL installs use `.vscode-server`; forks use their own dot-dir.
129
+ */
130
+ export function daemonOwnerIde(exePath) {
131
+ if (!exePath)
132
+ return null;
133
+ const p = exePath.replace(/\\/g, "/").toLowerCase();
134
+ const ides = [
135
+ [/\/\.vscode-server-insiders\/extensions\//, "VS Code Insiders (Remote)"],
136
+ [/\/\.vscode-server\/extensions\//, "VS Code (Remote/WSL)"],
137
+ [/\/\.vscode-insiders\/extensions\//, "VS Code Insiders"],
138
+ [/\/\.vscode-oss\/extensions\//, "VSCodium"],
139
+ [/\/\.vscode\/extensions\//, "VS Code"],
140
+ [/\/\.cursor-server\/extensions\//, "Cursor (Remote)"],
141
+ [/\/\.cursor\/extensions\//, "Cursor"],
142
+ [/\/\.windsurf-server\/extensions\//, "Windsurf (Remote)"],
143
+ [/\/\.windsurf\/extensions\//, "Windsurf"],
144
+ [/\/\.trae\/extensions\//, "Trae"],
145
+ [/\/\.antigravity\/extensions\//, "Antigravity"],
146
+ ];
147
+ for (const [re, label] of ides) {
148
+ if (re.test(p))
149
+ return label;
150
+ }
151
+ return null;
152
+ }
153
+ /** The remedy line for a daemon that is behind the installed engine. */
154
+ export function staleDaemonRemedy(exePath) {
155
+ const ide = daemonOwnerIde(exePath);
156
+ if (ide) {
157
+ return (`this daemon belongs to ${ide} (${exePath}) — 'vexp daemon-cmd restart' will NOT fix it: ` +
158
+ `the extension host respawns the same build.\n` +
159
+ ` reload the ${ide} window instead (Command Palette → "Developer: Reload Window").`);
160
+ }
161
+ return `run 'vexp daemon-cmd restart' to upgrade it now.`;
162
+ }
163
+ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
164
+ const ourHooksDir = path.join(repoRoot, ".git", "hooks");
165
+ if (!hooksPath) {
166
+ if (installedCount === 3) {
167
+ return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
168
+ }
169
+ if (installedCount === 0) {
170
+ return { level: OK, message: "no vexp git hooks (index refreshes on demand)" };
171
+ }
172
+ return {
173
+ level: WARN,
174
+ message: `only ${installedCount}/3 vexp git hooks present — re-run 'vexp hooks install'`,
175
+ };
176
+ }
177
+ const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(repoRoot, hooksPath);
178
+ if (path.resolve(resolved) === path.resolve(ourHooksDir)) {
179
+ return {
180
+ level: OK,
181
+ message: `core.hooksPath points at .git/hooks (${installedCount}/3 vexp hooks present)`,
182
+ };
183
+ }
184
+ if (installedCount === 0) {
185
+ return { level: OK, message: `core.hooksPath = ${resolved} (no vexp git hooks installed here)` };
186
+ }
187
+ return {
188
+ level: WARN,
189
+ message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in .git/hooks never run.\n` +
190
+ ` the index still refreshes: the daemon watches the tree live and reconciles it against disk every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).\n` +
191
+ ` 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.`,
192
+ };
193
+ }
106
194
  export async function runDoctor() {
107
195
  const home = vexpHome();
108
196
  let warns = 0;
@@ -149,7 +237,26 @@ export async function runDoctor() {
149
237
  : st.compressor ?? "unknown (older daemon)";
150
238
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
151
239
  if (st.llm_configured_but_inactive === true) {
152
- 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.`);
240
+ // The daemon knows WHY, and doctor was throwing it away: it printed
241
+ // the same "restart to load the model" the sidebar prints, to a user
242
+ // whose model fails on every start. Restarting is advice only when
243
+ // the cause can change between starts.
244
+ const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
245
+ const restartIsPointless = why !== undefined &&
246
+ /vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
247
+ // A daemon still running its startup sync has not REACHED the model
248
+ // yet. Restarting there is worse than useless: it starts the sync
249
+ // over, and the user never arrives.
250
+ const stillStarting = why !== undefined && /still completing its startup/i.test(why);
251
+ line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
252
+ (why ? `\n reason: ${why}` : "") +
253
+ (stillStarting
254
+ ? `\n wait for the sync to finish — do NOT restart, that begins it again` +
255
+ `\n the daemon logs 'LlmWorker active' when the model is up`
256
+ : restartIsPointless
257
+ ? `\n restarting will NOT change this — fix the cause above`
258
+ : `\n run 'vexp daemon-cmd restart' to load the model`) +
259
+ `\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`);
153
260
  }
154
261
  // 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
155
262
  // serving the OLD feature set and its gaps read as product bugs
@@ -162,10 +269,12 @@ export async function runDoctor() {
162
269
  const bundled = out.trim().split(/\s+/).pop();
163
270
  const running = st.daemon_version;
164
271
  if (bundled && running && bundled !== running) {
165
- 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.`);
272
+ const exe = st.daemon_exe;
273
+ 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)}`);
166
274
  }
167
275
  else if (st.binary_stale === true) {
168
- line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
276
+ const exe = st.daemon_exe;
277
+ line(WARN, `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(exe)}`);
169
278
  }
170
279
  }
171
280
  catch { /* best-effort */ }
@@ -435,35 +544,21 @@ export async function runDoctor() {
435
544
  return false;
436
545
  }
437
546
  });
438
- if (!hooksPath) {
439
- if (installed.length === 3)
440
- line(OK, "vexp hooks present in .git/hooks and git will run them");
441
- else if (installed.length === 0)
442
- line(OK, "no vexp git hooks (index refreshes on demand)");
443
- else
444
- line(WARN, `only ${installed.length}/3 vexp git hooks present — re-run 'vexp hooks install'`);
445
- }
446
- else {
447
- const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(ws.root, hooksPath);
448
- const sameDir = path.resolve(resolved) === path.resolve(ours);
449
- if (sameDir) {
450
- line(OK, `core.hooksPath points at .git/hooks (${installed.length}/3 vexp hooks present)`);
451
- }
452
- else if (installed.length > 0) {
453
- line(BAD, `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installed.length} vexp hook(s) in .git/hooks NEVER run and the index does not refresh on commit/merge/checkout. Add 'vexp index --finalize || true' to ${path.join(resolved, "pre-commit")}, or 'git config --unset core.hooksPath' for this repo.`);
454
- }
455
- else {
456
- line(OK, `core.hooksPath = ${resolved} (no vexp git hooks installed here)`);
457
- }
458
- }
547
+ const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
548
+ line(verdict.level, verdict.message);
459
549
  }
460
550
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
461
551
  {
462
552
  const sPath = path.join(ws.root, ".claude", "settings.json");
553
+ // `optIn` hooks are not written by a default setup, so their absence is
554
+ // the expected state and must not read as a fault. The verification gate
555
+ // buys correctness with TURNS, and a turn costs the whole transcript
556
+ // (65,521 tokens measured over 13 bench sessions); `vexp setup
557
+ // --interventions` installs it for anyone who wants that trade.
463
558
  const wanted = [
464
559
  { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
465
- { event: "Stop", marker: "vexp-verify", label: "verification gate" },
466
560
  { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
561
+ { event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
467
562
  ];
468
563
  let settings = null;
469
564
  try {
@@ -492,7 +587,12 @@ export async function runDoctor() {
492
587
  .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
493
588
  .find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
494
589
  if (!hook) {
495
- line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
590
+ if (w.optIn) {
591
+ line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
592
+ }
593
+ else {
594
+ line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
595
+ }
496
596
  continue;
497
597
  }
498
598
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
@@ -578,12 +678,23 @@ export async function runDoctor() {
578
678
  else if (!fs.existsSync(scriptPath)) {
579
679
  line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
580
680
  }
681
+ else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
682
+ // On Windows Codex runs the command through cmd.exe, which has no
683
+ // bash: without the commandWindows override the entry is inert on
684
+ // every prompt unless Git Bash happens to be on PATH. Probing it
685
+ // through bash here would hide exactly that.
686
+ line(BAD, "the entry has no 'commandWindows' — Codex runs hooks through cmd.exe on Windows, which cannot run the bash script, so orientation never fires (unless Git Bash is on PATH). Re-run 'vexp setup' to write the batch twin.");
687
+ }
581
688
  else {
582
- // Run it. The script bakes an absolute path to the vexp binary and
583
- // exits 0 when that path is not executable, so a stale or wrong-profile
584
- // path leaves NO trace anywhere: no orientation, no error, forever.
585
- // A Windows user found exactly that by reading the generated file.
586
- const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
689
+ // Run it, through the same shell Codex would use: cmd.exe on
690
+ // Windows (COMSPEC), sh elsewhere. The script bakes an absolute path
691
+ // to the vexp binary and exits 0 when that path is not executable, so
692
+ // a stale or wrong-profile path leaves NO trace anywhere: no
693
+ // orientation, no error, forever. A Windows user found exactly that
694
+ // by reading the generated file.
695
+ const isWin = process.platform === "win32";
696
+ const cmdLine = (isWin ? hook.commandWindows : hook.command);
697
+ const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
587
698
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
588
699
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
589
700
  timeout: 10000,
@@ -591,15 +702,17 @@ export async function runDoctor() {
591
702
  });
592
703
  const baked = (() => {
593
704
  try {
594
- const m = fs.readFileSync(scriptPath, "utf-8").match(/VEXP_BIN="([^"]+)"/);
595
- return m?.[1];
705
+ // One pattern covers both twins: VEXP_BIN="path" (bash) and
706
+ // set "VEXP_BIN=path" (cmd).
707
+ const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
708
+ return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
596
709
  }
597
710
  catch {
598
711
  return undefined;
599
712
  }
600
713
  })();
601
714
  if (r.error) {
602
- line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert. On Windows this usually means bash (Git Bash) is not on PATH.`);
715
+ line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
603
716
  }
604
717
  else if (r.status !== 0) {
605
718
  line(BAD, `hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Codex continues without vexp.`);
@@ -393,10 +393,192 @@ 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, "/"));
399
532
  }
533
+ /**
534
+ * Windows twin of the hint hook. Codex runs a hook command through
535
+ * `cmd.exe /C` (COMSPEC) on Windows, and cmd has no `bash`: a Windows user
536
+ * with neither Git Bash nor WSL got "bash not found" on every prompt, so
537
+ * orientation was inert and nothing said so. The logic is four lines and
538
+ * none of it is shell-specific, so it translates exactly — and `exit /b 0`
539
+ * on the last line keeps the same fail-open contract as the bash twin.
540
+ *
541
+ * CRLF on purpose: a batch file with bare LF endings is parsed
542
+ * inconsistently by cmd, and this one has to run on the machines least
543
+ * likely to have a POSIX toolchain that would normalise it.
544
+ */
545
+ export const VEXP_HINT_HOOK_CMD = [
546
+ "@echo off",
547
+ "REM vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.",
548
+ 'set "VEXP_BIN=__VEXP_BIN__"',
549
+ 'if not exist "%VEXP_BIN%" exit /b 0',
550
+ '"%VEXP_BIN%" prompt-hint 2>nul',
551
+ "exit /b 0",
552
+ "",
553
+ ].join("\r\n");
554
+ /** Bake the binary path into the Windows hint hook. Backslashes stay. */
555
+ export function vexpHintHookCmdScript(binaryPath) {
556
+ return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", binaryPath.replace(/\//g, "\\"));
557
+ }
558
+ /**
559
+ * v4 M1: the search answer (PreToolUse on Bash).
560
+ *
561
+ * Of 227 measured agent sessions with vexp installed, 9 called it — 4%. Bash
562
+ * is called in all of them. So this stops asking the model to adopt anything:
563
+ * when it is about to grep the repository for a name the index knows, the
564
+ * command is rewritten to ask vexp first, with the original grep preserved
565
+ * after a `||` so the failure mode is exactly "vexp is not installed".
566
+ *
567
+ * All the logic is in the binary (`vexp-core search-hook`), like prompt-hint:
568
+ * the script is a cross-OS wrapper that exits silently when anything is
569
+ * missing.
570
+ */
571
+ export const VEXP_SEARCH_HOOK = `#!/bin/bash
572
+ # vexp-search: answer a grep with structure instead of lines. Fails open.
573
+ VEXP_BIN="__VEXP_BIN__"
574
+ [ -x "$VEXP_BIN" ] || exit 0
575
+ "$VEXP_BIN" search-hook 2>/dev/null
576
+ exit 0
577
+ `;
578
+ /** Bake the binary path into the search hook script. */
579
+ export function vexpSearchHookScript(binaryPath) {
580
+ return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
581
+ }
400
582
  /**
401
583
  * Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
402
584
  * in the Rust binary (stop-gate): mechanical completion check via daemon,
@@ -468,6 +650,37 @@ export const VexpHint = async ({ directory, client }) => {
468
650
  }).catch(() => resolve(""));
469
651
  });
470
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
+ },
471
684
  "chat.message": async (input, output) => {
472
685
  try {
473
686
  const text = (output.parts || [])
@@ -491,7 +704,19 @@ export const VexpHint = async ({ directory, client }) => {
491
704
  });
492
705
  if (!out || !out.trim()) return;
493
706
  const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
494
- 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
+ }
495
720
  } catch (e) { /* fail open */ }
496
721
  },
497
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
  }