vexp-cli 2.7.0 → 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 +505 -453
- package/dist/cli.js +18 -11
- package/dist/doctor.js +63 -4
- package/dist/hook-template.js +177 -1
- package/dist/license.js +6 -2
- package/dist/secret-prompt.js +62 -0
- package/mcp/mcp-server.cjs +40 -38
- 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
|
@@ -113,6 +113,53 @@ function isAlive(pid) {
|
|
|
113
113
|
* the daemon watches the tree live and reconciles it against disk every five
|
|
114
114
|
* minutes — and the remedy was unusable. A user spent a round trip on it.
|
|
115
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
|
+
}
|
|
116
163
|
export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
|
|
117
164
|
const ourHooksDir = path.join(repoRoot, ".git", "hooks");
|
|
118
165
|
if (!hooksPath) {
|
|
@@ -222,10 +269,12 @@ export async function runDoctor() {
|
|
|
222
269
|
const bundled = out.trim().split(/\s+/).pop();
|
|
223
270
|
const running = st.daemon_version;
|
|
224
271
|
if (bundled && running && bundled !== running) {
|
|
225
|
-
|
|
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)}`);
|
|
226
274
|
}
|
|
227
275
|
else if (st.binary_stale === true) {
|
|
228
|
-
|
|
276
|
+
const exe = st.daemon_exe;
|
|
277
|
+
line(WARN, `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(exe)}`);
|
|
229
278
|
}
|
|
230
279
|
}
|
|
231
280
|
catch { /* best-effort */ }
|
|
@@ -501,10 +550,15 @@ export async function runDoctor() {
|
|
|
501
550
|
console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
|
|
502
551
|
{
|
|
503
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.
|
|
504
558
|
const wanted = [
|
|
505
559
|
{ event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
|
|
506
|
-
{ event: "Stop", marker: "vexp-verify", label: "verification gate" },
|
|
507
560
|
{ event: "SessionStart", marker: "vexp-restore", label: "context restore" },
|
|
561
|
+
{ event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
|
|
508
562
|
];
|
|
509
563
|
let settings = null;
|
|
510
564
|
try {
|
|
@@ -533,7 +587,12 @@ export async function runDoctor() {
|
|
|
533
587
|
.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
|
|
534
588
|
.find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
|
|
535
589
|
if (!hook) {
|
|
536
|
-
|
|
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
|
+
}
|
|
537
596
|
continue;
|
|
538
597
|
}
|
|
539
598
|
const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
|
package/dist/hook-template.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import * as readline from "node:readline";
|
|
3
|
+
export function mutableOutput(base) {
|
|
4
|
+
let muted = false;
|
|
5
|
+
const stream = new Writable({
|
|
6
|
+
write(chunk, enc, cb) {
|
|
7
|
+
if (!muted)
|
|
8
|
+
base.write(chunk);
|
|
9
|
+
cb();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
// readline consults these to decide how to render; without them it treats
|
|
13
|
+
// the wrapper as a dumb pipe and the prompt never appears.
|
|
14
|
+
Object.defineProperty(stream, "isTTY", { get: () => base.isTTY === true });
|
|
15
|
+
Object.defineProperty(stream, "columns", { get: () => base.columns });
|
|
16
|
+
Object.defineProperty(stream, "rows", { get: () => base.rows });
|
|
17
|
+
return { stream, setMuted: (m) => { muted = m; } };
|
|
18
|
+
}
|
|
19
|
+
/** Prompt for a secret on a fresh readline interface. */
|
|
20
|
+
export async function promptSecret(promptText, io) {
|
|
21
|
+
const { input, output, isTTY } = io;
|
|
22
|
+
if (!isTTY) {
|
|
23
|
+
// Piped: read the line with terminal mode OFF so readline cannot echo it.
|
|
24
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
25
|
+
output.write(promptText);
|
|
26
|
+
const line = await new Promise((resolve) => {
|
|
27
|
+
let settled = false;
|
|
28
|
+
rl.once("line", (l) => { settled = true; resolve(l); });
|
|
29
|
+
rl.once("close", () => { if (!settled)
|
|
30
|
+
resolve(""); });
|
|
31
|
+
});
|
|
32
|
+
rl.close();
|
|
33
|
+
output.write("\n");
|
|
34
|
+
return line;
|
|
35
|
+
}
|
|
36
|
+
const ctl = mutableOutput(output);
|
|
37
|
+
const rl = readline.createInterface({ input, output: ctl.stream, terminal: true });
|
|
38
|
+
try {
|
|
39
|
+
return await askSecretOn(rl, promptText, ctl, output);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
rl.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Prompt for a secret on an EXISTING interface — the interactive shell owns
|
|
47
|
+
* one readline for the whole session and closing it exits the process, so the
|
|
48
|
+
* shell mutes its own output instead of building a second one.
|
|
49
|
+
*
|
|
50
|
+
* The interface must have been created over `ctl.stream`.
|
|
51
|
+
*/
|
|
52
|
+
export async function askSecretOn(rl, promptText, ctl, output) {
|
|
53
|
+
const answer = await new Promise((resolve) => {
|
|
54
|
+
// question() writes the prompt synchronously, so muting immediately after
|
|
55
|
+
// hides the keystrokes and nothing else.
|
|
56
|
+
rl.question(promptText, (a) => resolve(a));
|
|
57
|
+
ctl.setMuted(true);
|
|
58
|
+
});
|
|
59
|
+
ctl.setMuted(false);
|
|
60
|
+
output.write("\n");
|
|
61
|
+
return answer;
|
|
62
|
+
}
|