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/README.md +1 -0
- package/dist/agent-config.js +594 -454
- package/dist/cli.js +18 -11
- package/dist/doctor.js +147 -34
- package/dist/hook-template.js +226 -1
- package/dist/license.js +6 -2
- package/dist/secret-prompt.js +62 -0
- package/mcp/mcp-server.cjs +43 -41
- package/package.json +6 -6
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
|
|
1047
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
|
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.`);
|
package/dist/hook-template.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
26
|
-
|
|
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
|
}
|