vexp-cli 3.3.6 → 3.3.8
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 +11 -0
- package/dist/agent-config.js +177 -52
- package/dist/cli.js +20 -0
- package/dist/doctor.js +235 -13
- package/dist/hook-template.js +56 -11
- package/mcp/mcp-server.cjs +31 -31
- package/package.json +6 -6
package/dist/doctor.js
CHANGED
|
@@ -605,6 +605,42 @@ function withoutVexpCommands(line) {
|
|
|
605
605
|
.filter((sentence) => !/[`']vexp /.test(sentence))
|
|
606
606
|
.join(" ");
|
|
607
607
|
}
|
|
608
|
+
/** ~/.vexp/config.toml, under VEXP_HOME when that is set: where the engine reads it. */
|
|
609
|
+
function configTomlPath() {
|
|
610
|
+
return path.join(vexpHome(), ".vexp", "config.toml");
|
|
611
|
+
}
|
|
612
|
+
/** Minutes as the setting gives them: whole, else seconds. */
|
|
613
|
+
function spokenMinutes(minutes) {
|
|
614
|
+
return Number.isInteger(minutes) ? `${minutes} min` : `${Math.round(minutes * 60)} s`;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The idle stop, as the daemon that answered sees it (index_status
|
|
618
|
+
* `idle_stop`, 3.3.6 and later). The setting was only to be found by reading
|
|
619
|
+
* the binary (field report, 2026-10-17): doctor names it, on, off, or passed
|
|
620
|
+
* over for a window's daemon. Null for a daemon from before it.
|
|
621
|
+
*/
|
|
622
|
+
export function idleStopFinding(idle) {
|
|
623
|
+
if (!idle || typeof idle !== "object")
|
|
624
|
+
return null;
|
|
625
|
+
const st = idle;
|
|
626
|
+
const after = typeof st.after_minutes === "number" && st.after_minutes > 0 ? st.after_minutes : null;
|
|
627
|
+
if (after === null) {
|
|
628
|
+
return {
|
|
629
|
+
level: INFO,
|
|
630
|
+
message: `idle stop: off, so this daemon runs until it is stopped. To stop the daemons nobody uses, set [daemon] idle_timeout_minutes = 30 in ${configTomlPath()} ` +
|
|
631
|
+
`(or VEXP_IDLE_TIMEOUT_MINUTES for one daemon); the next tool call or prompt starts one again. A daemon an editor window started stops with that window instead.`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (st.editor_owned === true) {
|
|
635
|
+
return {
|
|
636
|
+
level: OK,
|
|
637
|
+
message: `idle stop: after ${spokenMinutes(after)} without use, but not this daemon: an editor window started it, and it stops when that window closes`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const idleS = typeof st.idle_s === "number" ? st.idle_s : null;
|
|
641
|
+
const unused = idleS === null ? "" : idleS >= 60 ? ` (unused for ${Math.floor(idleS / 60)} min now)` : " (used in the last minute)";
|
|
642
|
+
return { level: OK, message: `idle stop: after ${spokenMinutes(after)} without use${unused}; the next tool call or prompt starts it again` };
|
|
643
|
+
}
|
|
608
644
|
/**
|
|
609
645
|
* What doctor says when no daemon answers for this workspace.
|
|
610
646
|
*
|
|
@@ -654,6 +690,18 @@ export function daemonDownFindings(o) {
|
|
|
654
690
|
},
|
|
655
691
|
];
|
|
656
692
|
}
|
|
693
|
+
// Stopped on purpose: idle_timeout_minutes. Nothing to restart by hand,
|
|
694
|
+
// and the marker goes as soon as a daemon starts here again.
|
|
695
|
+
const idle = (o.idleExit ?? "").split(/\r?\n/)[0].trim();
|
|
696
|
+
if (idle) {
|
|
697
|
+
return [
|
|
698
|
+
{
|
|
699
|
+
level: INFO,
|
|
700
|
+
message: `no daemon is running for this project (${o.sock}): it ${idle}.\n` +
|
|
701
|
+
` nothing to do: the next tool call or prompt starts it again. The setting is [daemon] idle_timeout_minutes in ${configTomlPath()}.`,
|
|
702
|
+
},
|
|
703
|
+
];
|
|
704
|
+
}
|
|
657
705
|
if (ext && !o.hasIndex) {
|
|
658
706
|
return [
|
|
659
707
|
{
|
|
@@ -1009,7 +1057,11 @@ export function partialParseVerdict(cov, coveragePath = ".vexp/coverage.json") {
|
|
|
1009
1057
|
const files = list;
|
|
1010
1058
|
const examples = files
|
|
1011
1059
|
.slice(0, 5)
|
|
1012
|
-
.map((f) =>
|
|
1060
|
+
.map((f) => {
|
|
1061
|
+
const broke = Number(f.broke_line) || 0;
|
|
1062
|
+
const at = broke > 0 ? `, the reading broke at line ${broke}` : "";
|
|
1063
|
+
return `${f.path} (${Number(f.lines) || 0} line(s) from line ${Number(f.first_line) || 0}${at})`;
|
|
1064
|
+
});
|
|
1013
1065
|
const more = files.length - examples.length;
|
|
1014
1066
|
return {
|
|
1015
1067
|
level: WARN,
|
|
@@ -1256,7 +1308,8 @@ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv =
|
|
|
1256
1308
|
if (!gitBash)
|
|
1257
1309
|
return null;
|
|
1258
1310
|
const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
|
|
1259
|
-
// The `
|
|
1311
|
+
// The `sh` inside the command (`bash` in an older entry) must resolve
|
|
1312
|
+
// beside Git Bash too, as in Claude Code, never to WSL's launcher.
|
|
1260
1313
|
if (path.win32.isAbsolute(gitBash)) {
|
|
1261
1314
|
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
|
|
1262
1315
|
const dir = path.win32.dirname(gitBash);
|
|
@@ -1278,10 +1331,108 @@ export function claudeShellMissing(what, lookup) {
|
|
|
1278
1331
|
}
|
|
1279
1332
|
return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
|
|
1280
1333
|
`(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
|
|
1281
|
-
`It then runs them with PowerShell, which cannot run vexp's
|
|
1334
|
+
`It then runs them with PowerShell, which cannot run vexp's sh hooks. A 'bash' on PATH does not count: with WSL installed it is WSL's, which cannot see this project.\n` +
|
|
1282
1335
|
` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
|
|
1283
1336
|
`(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
|
|
1284
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Does this Claude Code entry start vexp's script with `bash`? Setup wrote
|
|
1340
|
+
* that form until the hooks became POSIX sh; both installers now replace it,
|
|
1341
|
+
* matched by script name, with `sh "..."`. Off Windows Claude Code runs
|
|
1342
|
+
* either as `/bin/sh -c '<command>'`, so the old one keeps working wherever
|
|
1343
|
+
* a bash is on PATH, and doctor says nothing of it there.
|
|
1344
|
+
*/
|
|
1345
|
+
export function isBashHookEntry(command) {
|
|
1346
|
+
return /^\s*bash\s/.test(command);
|
|
1347
|
+
}
|
|
1348
|
+
/** Is `name` an executable file in a folder of PATH? POSIX lookup. */
|
|
1349
|
+
export function onPosixPath(name, env = process.env, canExec = (p) => {
|
|
1350
|
+
try {
|
|
1351
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
1352
|
+
return fs.statSync(p).isFile();
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
}) {
|
|
1358
|
+
return (env.PATH ?? "").split(path.posix.delimiter).some((dir) => dir !== "" && canExec(path.posix.join(dir, name)));
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* What a Claude Code hook that fails at its event costs. "Fails on every
|
|
1362
|
+
* prompt" was the line for the subagent hook too, which never sees a prompt:
|
|
1363
|
+
* on Agent the subagent still starts, only blind (review, 2026-10-24).
|
|
1364
|
+
*/
|
|
1365
|
+
export function claudeHookFailureCost(event) {
|
|
1366
|
+
if (event === "PreToolUse")
|
|
1367
|
+
return "Claude Code reports a hook error on every Agent call and each subagent starts without orientation";
|
|
1368
|
+
if (event === "SessionStart")
|
|
1369
|
+
return "the hook fails at every session start";
|
|
1370
|
+
if (event === "Stop")
|
|
1371
|
+
return "the hook fails at the end of every reply";
|
|
1372
|
+
return "the hook fails on every prompt";
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Which opt-ins this settings.json shows on. Setup keeps no record of its
|
|
1376
|
+
* flags, so the entries are the only answer, and a rewrite has to carry
|
|
1377
|
+
* them: `vexp setup` without --guard-strict or --interventions REMOVES those
|
|
1378
|
+
* hooks, script and entry (review, 2026-10-24).
|
|
1379
|
+
*/
|
|
1380
|
+
export function claudeOptInsOn(settings) {
|
|
1381
|
+
const hooks = settings?.hooks ?? {};
|
|
1382
|
+
const commands = Object.values(hooks)
|
|
1383
|
+
.flatMap((groups) => (Array.isArray(groups) ? groups : []))
|
|
1384
|
+
.flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
|
|
1385
|
+
.map((h) => (typeof h?.command === "string" ? h.command : ""));
|
|
1386
|
+
return {
|
|
1387
|
+
guard: commands.some((c) => c.includes("vexp-guard")),
|
|
1388
|
+
interventions: commands.some((c) => /vexp-(verify|edit-hint|read-hint|bash-cap)\b/.test(c)),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* How vexp's Claude Code entries get rewritten, with the install at hand:
|
|
1393
|
+
* the setup that writes them, and for someone with the extension a restart
|
|
1394
|
+
* of the window, since the extension rewrites them at every start in a
|
|
1395
|
+
* project where it sets Claude Code up (and the guard when vexp.guardStrict
|
|
1396
|
+
* is on).
|
|
1397
|
+
*
|
|
1398
|
+
* The project's opt-ins ride along (claudeOptInsOn). The plain command,
|
|
1399
|
+
* offered for an old `bash` stop gate, rewrote nothing: a setup without
|
|
1400
|
+
* --interventions takes the gate out, so a user who had chosen it and
|
|
1401
|
+
* followed doctor lost it without a word (review, 2026-10-24). The extension
|
|
1402
|
+
* writes none of the --interventions hooks and takes them out at every
|
|
1403
|
+
* start, which a reopen does too; the line says so rather than call that a
|
|
1404
|
+
* rewrite.
|
|
1405
|
+
*/
|
|
1406
|
+
export function claudeHooksRewriteRemedy(install, root, on = { guard: false, interventions: false }) {
|
|
1407
|
+
const ide = ideName(install.extensions);
|
|
1408
|
+
const reopen = on.guard
|
|
1409
|
+
? `reopen this project in ${ide} with the vexp.guardStrict setting on`
|
|
1410
|
+
: `reopen this project in ${ide} (the vexp extension rewrites them at every start where it sets Claude Code up)`;
|
|
1411
|
+
const takesOut = on.interventions
|
|
1412
|
+
? "; the extension takes out the stop gate and the other opt-in hooks of 'vexp setup --interventions' as it does so, since only the vexp CLI writes them"
|
|
1413
|
+
: "";
|
|
1414
|
+
if (extensionOnly(install)) {
|
|
1415
|
+
return (on.guard ? reopen : `${reopen}, or run "vexp: Setup Agents" from the Command Palette and tick Claude Code`) + takesOut;
|
|
1416
|
+
}
|
|
1417
|
+
const flags = `${on.guard ? " --guard-strict" : ""}${on.interventions ? " --interventions" : ""}`;
|
|
1418
|
+
const setup = `run '${setupAgentsCommand(["Claude Code"], root)}${flags}'`;
|
|
1419
|
+
return install.extensions.length > 0 ? `${setup}, or ${reopen}${takesOut}` : setup;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* The line for a `bash` entry on a machine with no bash. Alpine and the
|
|
1423
|
+
* other BusyBox systems ship sh alone, and the alpine-x64/arm64 builds put
|
|
1424
|
+
* vexp there: `bash "..."` exits 127 at every event, a hook error each time
|
|
1425
|
+
* and no orientation. Off Windows only: there Claude Code runs hooks with
|
|
1426
|
+
* Git Bash, which brings its own bash (claudeShellMissing covers its
|
|
1427
|
+
* absence). Null when the entry is not the old form or a bash is on PATH;
|
|
1428
|
+
* the probe that follows judges it then.
|
|
1429
|
+
*/
|
|
1430
|
+
export function claudeBashEntryLine(what, command, cost, remedy, platform = process.platform, bashOnPath = () => onPosixPath("bash")) {
|
|
1431
|
+
if (platform === "win32" || !isBashHookEntry(command) || bashOnPath())
|
|
1432
|
+
return null;
|
|
1433
|
+
return (`${what} starts vexp's script with bash, and this machine has no bash on PATH (Alpine and other BusyBox systems ship only sh): ${cost}.\n` +
|
|
1434
|
+
` vexp writes this entry with sh now; to fix it: ${remedy}.`);
|
|
1435
|
+
}
|
|
1285
1436
|
/**
|
|
1286
1437
|
* What the ledger adds to a failed hook probe. Its prompts arrive only
|
|
1287
1438
|
* through an agent's hook — doctor's own probe is tagged manual and never
|
|
@@ -1291,14 +1442,36 @@ export function claudeShellMissing(what, lookup) {
|
|
|
1291
1442
|
* probes' own failures: the prompts it counts come from UserPromptSubmit
|
|
1292
1443
|
* hooks, and next to a failure that is real (no Git Bash for Claude Code, an
|
|
1293
1444
|
* exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
|
|
1294
|
-
* reader out of the one line he had to act on (review, 2026-09-24).
|
|
1445
|
+
* reader out of the one line he had to act on (review, 2026-09-24). And only
|
|
1446
|
+
* on the UserPromptSubmit probe's: a count of prompts says nothing of
|
|
1447
|
+
* vexp-subagent.sh, and an exit 2 line read "Claude Code REFUSES the Agent
|
|
1448
|
+
* call" and "may be the probe's alone" at once (review, 2026-10-24).
|
|
1295
1449
|
*/
|
|
1296
|
-
export function hookLedgerNote(hookPrompts7d) {
|
|
1297
|
-
if (!(hookPrompts7d > 0))
|
|
1450
|
+
export function hookLedgerNote(hookPrompts7d, event) {
|
|
1451
|
+
if (event !== "UserPromptSubmit" || !(hookPrompts7d > 0))
|
|
1298
1452
|
return "";
|
|
1299
1453
|
return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
|
|
1300
1454
|
`(doctor's own probe is never counted), so an agent's hooks do reach vexp in this project — this failure may be the probe's alone.`);
|
|
1301
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* The keys of the Agent call the subagent hook's answer leaves out. Claude
|
|
1458
|
+
* Code replaces tool_input with updatedInput instead of merging the two, so
|
|
1459
|
+
* a key missing there is missing from the call itself: the agent type, the
|
|
1460
|
+
* background flag. No output, or output without updatedInput, leaves the
|
|
1461
|
+
* call as the model wrote it and drops nothing.
|
|
1462
|
+
*/
|
|
1463
|
+
export function droppedAgentKeys(stdout, toolInput) {
|
|
1464
|
+
let updated;
|
|
1465
|
+
try {
|
|
1466
|
+
updated = JSON.parse(String(stdout ?? "").trim() || "null")?.hookSpecificOutput?.updatedInput;
|
|
1467
|
+
}
|
|
1468
|
+
catch {
|
|
1469
|
+
return [];
|
|
1470
|
+
}
|
|
1471
|
+
if (!updated || typeof updated !== "object")
|
|
1472
|
+
return [];
|
|
1473
|
+
return Object.keys(toolInput).filter((k) => !(k in updated));
|
|
1474
|
+
}
|
|
1302
1475
|
/** The answers file when it exists but does not read as answers. */
|
|
1303
1476
|
function unreadableAnswers(file) {
|
|
1304
1477
|
// The same loader the CLI and the extension read the answers with: it
|
|
@@ -3257,6 +3430,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
3257
3430
|
startBlocked = fs.readFileSync(path.join(ws.root, ".vexp", "start-blocked"), "utf-8");
|
|
3258
3431
|
}
|
|
3259
3432
|
catch { /* the last start was not refused */ }
|
|
3433
|
+
let idleExit;
|
|
3434
|
+
try {
|
|
3435
|
+
idleExit = fs.readFileSync(path.join(ws.root, ".vexp", "idle-exit"), "utf-8");
|
|
3436
|
+
}
|
|
3437
|
+
catch { /* the last daemon did not stop for idleness */ }
|
|
3260
3438
|
const what = notAProject(ws.root);
|
|
3261
3439
|
const down = daemonDownFindings({
|
|
3262
3440
|
sock,
|
|
@@ -3264,6 +3442,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3264
3442
|
hasIndex: fs.existsSync(path.join(ws.root, ".vexp", "index.db")),
|
|
3265
3443
|
startBlocked,
|
|
3266
3444
|
outside: what ? { root: ws.root, what } : undefined,
|
|
3445
|
+
idleExit,
|
|
3267
3446
|
});
|
|
3268
3447
|
closedInIde = down.every((f) => f.level === INFO);
|
|
3269
3448
|
for (const f of down)
|
|
@@ -3303,6 +3482,9 @@ async function doctorChecks(onWorkspace) {
|
|
|
3303
3482
|
? "loading (the model is still starting — this is not the rule compressor settling in)"
|
|
3304
3483
|
: st.compressor ?? "unknown (older daemon)";
|
|
3305
3484
|
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
3485
|
+
const idleStop = idleStopFinding(st.idle_stop);
|
|
3486
|
+
if (idleStop)
|
|
3487
|
+
emit(idleStop);
|
|
3306
3488
|
if (st.llm_configured_but_inactive === true) {
|
|
3307
3489
|
// The daemon knows WHY, and doctor was throwing it away: it printed
|
|
3308
3490
|
// the same "restart to load the model" the sidebar prints, to a user
|
|
@@ -3759,9 +3941,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
3759
3941
|
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
3760
3942
|
let guardHooks = [];
|
|
3761
3943
|
let settingsReadable = false;
|
|
3944
|
+
let optIns = { guard: true, interventions: false };
|
|
3762
3945
|
try {
|
|
3763
3946
|
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
3764
3947
|
settingsReadable = true;
|
|
3948
|
+
optIns = { ...claudeOptInsOn(settings), guard: true };
|
|
3765
3949
|
const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
3766
3950
|
for (const m of pre) {
|
|
3767
3951
|
const hks = Array.isArray(m?.hooks) ? m.hooks : [];
|
|
@@ -3791,7 +3975,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3791
3975
|
// all — the entry LOOKS installed and enforces nothing. This was
|
|
3792
3976
|
// invisible for days because doctor used to skip the live check
|
|
3793
3977
|
// on Windows entirely.
|
|
3794
|
-
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (
|
|
3978
|
+
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (sh-prefixed shell form).`);
|
|
3795
3979
|
}
|
|
3796
3980
|
if (timeoutS > 600) {
|
|
3797
3981
|
line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
|
|
@@ -3800,6 +3984,15 @@ async function doctorChecks(onWorkspace) {
|
|
|
3800
3984
|
line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
|
|
3801
3985
|
continue;
|
|
3802
3986
|
}
|
|
3987
|
+
// An entry from before the hooks became POSIX sh, on a machine with
|
|
3988
|
+
// no bash (Alpine): the probe would only print sh's exit 127.
|
|
3989
|
+
const noBash = execForm
|
|
3990
|
+
? null
|
|
3991
|
+
: claudeBashEntryLine("the guard hook", cmd, "Claude Code reports a hook error on every search and the guard enforces nothing", claudeHooksRewriteRemedy(install, ws.root, optIns));
|
|
3992
|
+
if (noBash) {
|
|
3993
|
+
line(BAD, noBash);
|
|
3994
|
+
continue;
|
|
3995
|
+
}
|
|
3803
3996
|
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
3804
3997
|
// the placeholder substituted by the host; shell form = the shell
|
|
3805
3998
|
// Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
|
|
@@ -3887,6 +4080,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3887
4080
|
// --interventions` installs it for anyone who wants that trade.
|
|
3888
4081
|
const wanted = [
|
|
3889
4082
|
{ event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
|
|
4083
|
+
{ event: "PreToolUse", marker: "vexp-subagent", label: "subagent orientation" },
|
|
3890
4084
|
{ event: "SessionStart", marker: "vexp-restore", label: "context restore" },
|
|
3891
4085
|
{ event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
|
|
3892
4086
|
];
|
|
@@ -3935,15 +4129,31 @@ async function doctorChecks(onWorkspace) {
|
|
|
3935
4129
|
}
|
|
3936
4130
|
const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
|
|
3937
4131
|
if (!fs.existsSync(scriptPath)) {
|
|
3938
|
-
|
|
4132
|
+
// sh exits 127, an error Claude Code reports and steps over each
|
|
4133
|
+
// time the event fires (claudeHookFailureCost says what that costs).
|
|
4134
|
+
line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — ${claudeHookFailureCost(w.event)}.`);
|
|
4135
|
+
continue;
|
|
4136
|
+
}
|
|
4137
|
+
// A `bash` entry from before the hooks became POSIX sh, where there
|
|
4138
|
+
// is no bash: the same 127 at every event, and a rewrite fixes it.
|
|
4139
|
+
const noBash = claudeBashEntryLine(`${w.event} hook`, hook.command, claudeHookFailureCost(w.event), claudeHooksRewriteRemedy(install, ws.root, claudeOptInsOn(settings)));
|
|
4140
|
+
if (noBash) {
|
|
4141
|
+
line(BAD, noBash);
|
|
3939
4142
|
continue;
|
|
3940
4143
|
}
|
|
3941
4144
|
// Feed it a payload shaped like the real event and see it survive.
|
|
4145
|
+
// Like every probe here it carries no hook_event_name and no
|
|
4146
|
+
// transcript_path: those are what tell the engine a call came from an
|
|
4147
|
+
// agent (payload_is_from_hook, main.rs), and a probe with them would
|
|
4148
|
+
// be counted in the activity ledger as an agent's.
|
|
4149
|
+
const agentInput = { description: "vexp doctor probe", prompt: "vexp doctor probe", subagent_type: "general-purpose" };
|
|
3942
4150
|
const payload = JSON.stringify(w.event === "SessionStart"
|
|
3943
4151
|
? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
|
|
3944
4152
|
: w.event === "Stop"
|
|
3945
4153
|
? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
|
|
3946
|
-
:
|
|
4154
|
+
: w.event === "PreToolUse"
|
|
4155
|
+
? { session_id: "vexp-doctor", tool_name: "Agent", tool_input: agentInput, cwd: ws.root }
|
|
4156
|
+
: { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
|
|
3947
4157
|
// In Claude Code's own shell (claudeHookSpawnSpec), from the project.
|
|
3948
4158
|
const bash = process.platform === "win32" ? claudeGitBash() : null;
|
|
3949
4159
|
const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
|
|
@@ -3959,15 +4169,27 @@ async function doctorChecks(onWorkspace) {
|
|
|
3959
4169
|
encoding: "utf-8",
|
|
3960
4170
|
windowsHide: true,
|
|
3961
4171
|
});
|
|
4172
|
+
const lost = w.event === "PreToolUse" && !r.error && r.status === 0 ? droppedAgentKeys(r.stdout, agentInput) : [];
|
|
3962
4173
|
if (r.error) {
|
|
3963
|
-
line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d));
|
|
4174
|
+
line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d, w.event));
|
|
3964
4175
|
}
|
|
3965
4176
|
else if (r.status !== 0) {
|
|
3966
|
-
|
|
4177
|
+
// Exit 2 from a PreToolUse hook is not a failure Claude Code steps
|
|
4178
|
+
// over: it refuses the tool call. The script setup writes ends on
|
|
4179
|
+
// `exit 0` for exactly this reason, so one that exits 2 is not it.
|
|
4180
|
+
const consequence = w.event === "PreToolUse" && r.status === 2
|
|
4181
|
+
? "on exit 2 Claude Code REFUSES the Agent call, so no subagent can start"
|
|
4182
|
+
: "Claude Code treats this as a failure and continues without vexp";
|
|
4183
|
+
line(BAD, `${w.event} hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — ${consequence}.` + hookLedgerNote(hookPrompts7d, w.event));
|
|
4184
|
+
}
|
|
4185
|
+
else if (lost.length > 0) {
|
|
4186
|
+
line(BAD, `${w.event} hook rewrites the Agent call without ${lost.join(", ")} — Claude Code replaces the call's input with the hook's, so every subagent loses them; update the vexp engine.`);
|
|
3967
4187
|
}
|
|
3968
4188
|
else {
|
|
3969
4189
|
// Silence is a legitimate outcome for the hint hook (the classifier
|
|
3970
|
-
// decides), so report the daemon's reason instead of guessing.
|
|
4190
|
+
// decides), so report the daemon's reason instead of guessing. The
|
|
4191
|
+
// same for the subagent hook: no seed is no output, and the call
|
|
4192
|
+
// goes ahead as the model wrote it.
|
|
3971
4193
|
const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
|
|
3972
4194
|
line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
|
|
3973
4195
|
}
|
|
@@ -4053,7 +4275,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
4053
4275
|
: [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
|
|
4054
4276
|
const baked = (() => {
|
|
4055
4277
|
try {
|
|
4056
|
-
// One pattern covers both twins: VEXP_BIN="path" (
|
|
4278
|
+
// One pattern covers both twins: VEXP_BIN="path" (sh) and
|
|
4057
4279
|
// set "VEXP_BIN=path" (cmd).
|
|
4058
4280
|
const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
|
|
4059
4281
|
return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
|
package/dist/hook-template.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* platform uses the right "daemon is listening" signal. The healthy marker
|
|
12
12
|
* (removed by the daemon on graceful shutdown) gates both branches.
|
|
13
13
|
*/
|
|
14
|
-
export const VEXP_GUARD_HOOK = `#!/bin/
|
|
14
|
+
export const VEXP_GUARD_HOOK = `#!/bin/sh
|
|
15
15
|
# vexp-guard: block Grep/Glob/Read when the vexp daemon is running AND healthy.
|
|
16
16
|
# Cross-platform: Unix/macOS use the domain socket file (.vexp/daemon.sock);
|
|
17
17
|
# Windows (Git Bash) has no socket file, so the daemon writes .vexp/daemon.pipe.
|
|
@@ -31,7 +31,7 @@ vexp_allow() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
# Healthy marker is the portable "daemon is up" signal; the daemon removes it on
|
|
34
|
-
# graceful shutdown. Required on every platform
|
|
34
|
+
# graceful shutdown. Required on every platform - if it's missing, fail open.
|
|
35
35
|
[ -f "$HEALTHY" ] || vexp_allow
|
|
36
36
|
|
|
37
37
|
case "$(uname -s 2>/dev/null)" in
|
|
@@ -207,8 +207,8 @@ export const VexpGuard = async ({ directory, worktree }) => {
|
|
|
207
207
|
* never touch the shell, never block a target vexp did not index — expressed in
|
|
208
208
|
* the third dialect we now have to speak.
|
|
209
209
|
*
|
|
210
|
-
* Written in NODE, not
|
|
211
|
-
*
|
|
210
|
+
* Written in NODE, not shell, and that is deliberate. The Claude Code guard is a
|
|
211
|
+
* shell script and it silently enforced NOTHING on Windows for several releases
|
|
212
212
|
* (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
|
|
213
213
|
* expensive bug this file has produced. Cursor documents no PowerShell variant
|
|
214
214
|
* of `command` and does not say which shell runs it on Windows, so a bash guard
|
|
@@ -381,6 +381,15 @@ process.stdin.on("end", () => {
|
|
|
381
381
|
* hint suggesting run_pipeline. All logic (stdin parsing, classification,
|
|
382
382
|
* hook-JSON output) lives in the Rust binary so the script is OS-neutral;
|
|
383
383
|
* Claude Code runs shell-form hooks through Git Bash on Windows.
|
|
384
|
+
*
|
|
385
|
+
* POSIX sh, like every Claude Code hook script in this file, and registered
|
|
386
|
+
* as `sh "$CLAUDE_PROJECT_DIR/..."`. They were bash scripts run by `bash`,
|
|
387
|
+
* and Alpine (BusyBox) has no bash: on an Alpine remote, the platform of the
|
|
388
|
+
* alpine-x64/arm64 builds, every prompt, session start and Agent call
|
|
389
|
+
* reported a hook error and got no orientation. Nothing here needed bash.
|
|
390
|
+
* Keep it that way: test/hook-posix.test.ts rejects the usual bashisms and
|
|
391
|
+
* runs every baked script under dash.
|
|
392
|
+
*
|
|
384
393
|
* FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
|
|
385
394
|
* timeout) exits 0 with no output = vanilla behavior, never a broken
|
|
386
395
|
* prompt. Keep in lockstep with the VS Code extension copy.
|
|
@@ -392,13 +401,45 @@ process.stdin.on("end", () => {
|
|
|
392
401
|
* line: Codex hashes that line for its trust record, and a change there would
|
|
393
402
|
* make every user approve the hook again. Binaries that predate it ignore it.
|
|
394
403
|
*/
|
|
395
|
-
export const VEXP_HINT_HOOK = `#!/bin/
|
|
404
|
+
export const VEXP_HINT_HOOK = `#!/bin/sh
|
|
396
405
|
# vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
|
|
397
406
|
VEXP_BIN="__VEXP_BIN__"
|
|
398
407
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
399
408
|
VEXP_HOOK_AGENT="__VEXP_AGENT__" "$VEXP_BIN" prompt-hint 2>/dev/null
|
|
400
409
|
exit 0
|
|
401
410
|
`;
|
|
411
|
+
/**
|
|
412
|
+
* Orientation for a delegated task: PreToolUse on Claude Code's Agent tool.
|
|
413
|
+
*
|
|
414
|
+
* A subagent started blind (field report, 2026-10-24). UserPromptSubmit fires
|
|
415
|
+
* for what the user types and never for the prompt the model writes for a
|
|
416
|
+
* subagent; the built-in Explore and Plan agents run without CLAUDE.md; and
|
|
417
|
+
* vexp's tools reach a subagent only as bare deferred names. SubagentStart is
|
|
418
|
+
* no way in either: it is handed the agent's id and type, not its task.
|
|
419
|
+
*
|
|
420
|
+
* PreToolUse on Agent is handed the task, as tool_input.prompt, and Claude
|
|
421
|
+
* Code starts the subagent on `updatedInput` when the hook returns one. It
|
|
422
|
+
* REPLACES the input, it does not merge it, so `subagent-hint` echoes every
|
|
423
|
+
* key with the seed appended to the prompt. It sets no permissionDecision:
|
|
424
|
+
* without one the rewrite is applied silently in default, acceptEdits, auto,
|
|
425
|
+
* plan and bypassPermissions mode (verified on Claude Code 2.1.217 and
|
|
426
|
+
* 2.1.309), and an "allow" would skip the auto-mode classifier. The parent's
|
|
427
|
+
* transcript keeps the prompt the model wrote; only the subagent's first
|
|
428
|
+
* message carries the seed, so only the subagent pays for it.
|
|
429
|
+
*
|
|
430
|
+
* The last `exit 0` matters more here than in any other hook: a PreToolUse
|
|
431
|
+
* hook that exits 2 BLOCKS the tool call, and an engine older than
|
|
432
|
+
* `subagent-hint` exits 2 from its argument parser. Without it, every
|
|
433
|
+
* delegation would be refused until the engine was updated. Claude Code is
|
|
434
|
+
* the only agent that runs this script, hence the agent name written in.
|
|
435
|
+
*/
|
|
436
|
+
export const VEXP_SUBAGENT_HOOK = `#!/bin/sh
|
|
437
|
+
# vexp-subagent: orientation for a delegated task (PreToolUse on Agent). Fails open.
|
|
438
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
439
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
440
|
+
VEXP_HOOK_AGENT="claude-code" "$VEXP_BIN" subagent-hint 2>/dev/null
|
|
441
|
+
exit 0
|
|
442
|
+
`;
|
|
402
443
|
/**
|
|
403
444
|
* v5: the coupling, delivered on the edit that needs it (PostToolUse).
|
|
404
445
|
*
|
|
@@ -417,14 +458,14 @@ exit 0
|
|
|
417
458
|
* FAIL-OPEN like every other hook: no binary, no daemon, nothing to say —
|
|
418
459
|
* exit 0 silent.
|
|
419
460
|
*/
|
|
420
|
-
export const VEXP_EDIT_HINT_HOOK = `#!/bin/
|
|
461
|
+
export const VEXP_EDIT_HINT_HOOK = `#!/bin/sh
|
|
421
462
|
# vexp-edit-hint: what else references the file you just changed. Fails open.
|
|
422
463
|
VEXP_BIN="__VEXP_BIN__"
|
|
423
464
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
424
465
|
"$VEXP_BIN" edit-hint 2>/dev/null
|
|
425
466
|
exit 0
|
|
426
467
|
`;
|
|
427
|
-
export const VEXP_READ_HINT_HOOK = `#!/bin/
|
|
468
|
+
export const VEXP_READ_HINT_HOOK = `#!/bin/sh
|
|
428
469
|
# vexp-read-hint: answer a whole-file read of a large file with its skeleton.
|
|
429
470
|
# The only mechanism vexp has that SUBTRACTS tokens. Fails open.
|
|
430
471
|
VEXP_BIN="__VEXP_BIN__"
|
|
@@ -432,7 +473,7 @@ VEXP_BIN="__VEXP_BIN__"
|
|
|
432
473
|
"$VEXP_BIN" read-hint 2>/dev/null
|
|
433
474
|
exit 0
|
|
434
475
|
`;
|
|
435
|
-
export const VEXP_BASH_CAP_HOOK = `#!/bin/
|
|
476
|
+
export const VEXP_BASH_CAP_HOOK = `#!/bin/sh
|
|
436
477
|
# vexp-bash-cap: bound the output of a shell command that has no bound of its
|
|
437
478
|
# own. The other door: narrowing reads alone just moved the work here. Fails
|
|
438
479
|
# open, and never touches a build or a test.
|
|
@@ -548,6 +589,10 @@ export function bakeEditHintHook(binaryPath) {
|
|
|
548
589
|
export function vexpHintHookScript(binaryPath, agent = "claude-code") {
|
|
549
590
|
return VEXP_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/")).replace("__VEXP_AGENT__", agent);
|
|
550
591
|
}
|
|
592
|
+
/** Bake the binary path into the subagent hook script. */
|
|
593
|
+
export function vexpSubagentHookScript(binaryPath) {
|
|
594
|
+
return VEXP_SUBAGENT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
|
|
595
|
+
}
|
|
551
596
|
/**
|
|
552
597
|
* Windows twin of the hint hook, for Codex. A Windows user with neither Git
|
|
553
598
|
* Bash nor WSL has no `bash`, so `bash "..."` failed on every prompt and
|
|
@@ -611,7 +656,7 @@ export function codexWindowsHookCommand(cmdPath) {
|
|
|
611
656
|
* the script is a cross-OS wrapper that exits silently when anything is
|
|
612
657
|
* missing.
|
|
613
658
|
*/
|
|
614
|
-
export const VEXP_SEARCH_HOOK = `#!/bin/
|
|
659
|
+
export const VEXP_SEARCH_HOOK = `#!/bin/sh
|
|
615
660
|
# vexp-search: answer a grep with structure instead of lines. Fails open.
|
|
616
661
|
VEXP_BIN="__VEXP_BIN__"
|
|
617
662
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
@@ -629,7 +674,7 @@ export function vexpSearchHookScript(binaryPath) {
|
|
|
629
674
|
* imports. FAIL-OPEN by construction: no binary / no daemon / timeout =
|
|
630
675
|
* silent exit 0 = normal stop.
|
|
631
676
|
*/
|
|
632
|
-
export const VEXP_STOP_GATE_HOOK = `#!/bin/
|
|
677
|
+
export const VEXP_STOP_GATE_HOOK = `#!/bin/sh
|
|
633
678
|
# vexp-verify: mechanical completion gate on Stop (Horizon). Fails open.
|
|
634
679
|
VEXP_BIN="__VEXP_BIN__"
|
|
635
680
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
@@ -647,7 +692,7 @@ export function vexpStopGateHookScript(binaryPath) {
|
|
|
647
692
|
* orientation) and re-injects one compact context paragraph. FAIL-OPEN:
|
|
648
693
|
* no binary / no daemon / fresh start = silent exit 0.
|
|
649
694
|
*/
|
|
650
|
-
export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/
|
|
695
|
+
export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/sh
|
|
651
696
|
# vexp-restore: context lifecycle restore on SessionStart (compact/resume). Fails open.
|
|
652
697
|
VEXP_BIN="__VEXP_BIN__"
|
|
653
698
|
[ -x "$VEXP_BIN" ] || exit 0
|