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/README.md
CHANGED
|
@@ -60,6 +60,17 @@ vexp shield scan
|
|
|
60
60
|
vexp doctor
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## Daemons and memory
|
|
64
|
+
|
|
65
|
+
vexp runs one small daemon per project, started by your agent's first call. By default it runs until you stop it (`vexp stop`, or `vexp stop --all`). To have the daemons nobody is using stop on their own, add this to `~/.vexp/config.toml`:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[daemon]
|
|
69
|
+
idle_timeout_minutes = 30
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The next tool call or prompt starts the daemon again. `VEXP_IDLE_TIMEOUT_MINUTES` sets it for one daemon. A daemon the VS Code extension started for a window stops when that window closes. `vexp daemons` lists what is running, with each daemon's memory, and `vexp doctor` shows whether the setting is on.
|
|
73
|
+
|
|
63
74
|
## Supported agents
|
|
64
75
|
|
|
65
76
|
Claude Code · Cursor · Windsurf · GitHub Copilot · OpenAI Codex · Zed · Continue.dev · Cline · Aider · Kiro · Kilo Code · opencode · Augment · Antigravity · ZCode · Friday Code
|
package/dist/agent-config.js
CHANGED
|
@@ -13,7 +13,7 @@ import { spawnSync } from "child_process";
|
|
|
13
13
|
import * as crypto from "crypto";
|
|
14
14
|
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
15
15
|
import { readAgentConsent, consentKey, decisionForKey } from "./agent-consent.js";
|
|
16
|
-
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, codexWindowsHookCommand, opencodeFamilyAgent, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook, upperDriveLetter } from "./hook-template.js";
|
|
16
|
+
import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSubagentHookScript, vexpSearchHookScript, vexpHintHookCmdScript, codexWindowsHookCommand, opencodeFamilyAgent, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook, upperDriveLetter } from "./hook-template.js";
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Constants
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
@@ -561,6 +561,7 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
|
|
|
561
561
|
case "Claude Code":
|
|
562
562
|
out.push("~/.claude.json (MCP server entry, user scope)");
|
|
563
563
|
out.push(".claude/hooks/vexp-hint.sh + settings.json hooks.UserPromptSubmit (orientation)");
|
|
564
|
+
out.push(".claude/hooks/vexp-subagent.sh + settings.json hooks.PreToolUse on Agent (subagent orientation)");
|
|
564
565
|
out.push(".claude/hooks/vexp-restore.sh + settings.json hooks.SessionStart (context restore)");
|
|
565
566
|
if (interventions === "on") {
|
|
566
567
|
out.push(".claude/hooks/vexp-verify.sh + settings.json hooks.Stop (verification gate, opt-in)");
|
|
@@ -734,6 +735,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
|
|
|
734
735
|
// Event-driven hint hook: default-on (non-blocking, fail-open — the
|
|
735
736
|
// 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
|
|
736
737
|
const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
738
|
+
installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
|
|
737
739
|
installClaudeCodeSearchHook(workspaceRoot, binaryPath);
|
|
738
740
|
// Both doors. Bounding either alone measured as no change at all: the
|
|
739
741
|
// work simply moves to the other one.
|
|
@@ -1109,6 +1111,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
|
|
|
1109
1111
|
if (wrote)
|
|
1110
1112
|
mcpConfigs.push("~/.claude.json");
|
|
1111
1113
|
installClaudeCodeHintHook(workspaceRoot, binaryPath);
|
|
1114
|
+
installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
|
|
1112
1115
|
if (interventionMode() === "on") {
|
|
1113
1116
|
installClaudeCodeReadHint(workspaceRoot, binaryPath);
|
|
1114
1117
|
installClaudeCodeBashCap(workspaceRoot, binaryPath);
|
|
@@ -3045,8 +3048,9 @@ export function uninstallClaudeCodeHook(workspaceRoot) {
|
|
|
3045
3048
|
const settings = read.data;
|
|
3046
3049
|
const hooks = (settings.hooks ?? {});
|
|
3047
3050
|
const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3048
|
-
|
|
3049
|
-
|
|
3051
|
+
// vexp's hooks out, a user's hook in their group kept (placeVexpHook).
|
|
3052
|
+
const filtered = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, null);
|
|
3053
|
+
if (JSON.stringify(filtered) !== JSON.stringify(existingPreToolUse)) {
|
|
3050
3054
|
if (filtered.length > 0)
|
|
3051
3055
|
hooks.PreToolUse = filtered;
|
|
3052
3056
|
else
|
|
@@ -3087,9 +3091,10 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3087
3091
|
const settings = read.data;
|
|
3088
3092
|
const hooks = (settings.hooks ?? {});
|
|
3089
3093
|
const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3090
|
-
// Aggressively remove ALL vexp-related hook entries (old format, stale
|
|
3091
|
-
|
|
3092
|
-
//
|
|
3094
|
+
// Aggressively remove ALL vexp-related hook entries (old format, stale
|
|
3095
|
+
// matchers, malformed); ours goes where the first of them stood, and a
|
|
3096
|
+
// user's hook in that group stays (placeVexpHook).
|
|
3097
|
+
// Shell form with an explicit `sh` prefix and a QUOTED path — the one
|
|
3093
3098
|
// shape that works on every OS Claude Code supports:
|
|
3094
3099
|
// - the quotes fix the historical spaces bug (unquoted
|
|
3095
3100
|
// `$CLAUDE_PROJECT_DIR/...` word-split and the guard never ran);
|
|
@@ -3097,15 +3102,20 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3097
3102
|
// Windows entirely: the OS cannot spawn a `.sh` without a shell, the
|
|
3098
3103
|
// hook errored, failed open, and enforced nothing (field report) —
|
|
3099
3104
|
// Claude Code on Windows runs shell-form hooks through Git Bash, so
|
|
3100
|
-
// `
|
|
3105
|
+
// `sh "$VAR"` works there and on unix alike.
|
|
3106
|
+
// - `sh`, not the `bash` of earlier entries: Alpine (BusyBox) has no
|
|
3107
|
+
// bash, so every vexp hook failed there. On Windows Claude Code puts
|
|
3108
|
+
// Git Bash's folder first on PATH, and Git for Windows ships sh.exe
|
|
3109
|
+
// beside bash.exe, so `sh` resolves there and never to WSL's bash.exe.
|
|
3110
|
+
// An old `bash` entry is matched by its script name and replaced.
|
|
3101
3111
|
// `timeout` is SECONDS (default 600), not ms: 3000 once configured a
|
|
3102
3112
|
// 50-minute hook timeout.
|
|
3103
|
-
|
|
3113
|
+
const placed = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, {
|
|
3104
3114
|
matcher: "Grep|Glob|Regex",
|
|
3105
3115
|
hooks: [
|
|
3106
3116
|
{
|
|
3107
3117
|
type: "command",
|
|
3108
|
-
command: '
|
|
3118
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh"',
|
|
3109
3119
|
timeout: 5,
|
|
3110
3120
|
},
|
|
3111
3121
|
],
|
|
@@ -3113,7 +3123,7 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3113
3123
|
// Idempotence check AFTER the merge is computed: skip the write (and the
|
|
3114
3124
|
// backup) only when settings already contain exactly the entry we would
|
|
3115
3125
|
// write AND the script was already current.
|
|
3116
|
-
const merged = { ...hooks, PreToolUse:
|
|
3126
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3117
3127
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3118
3128
|
if (scriptIdentical && settingsIdentical)
|
|
3119
3129
|
return null;
|
|
@@ -3138,6 +3148,63 @@ function isVexpHookEntry(h, needle) {
|
|
|
3138
3148
|
return hook.command?.includes(needle) ?? false;
|
|
3139
3149
|
});
|
|
3140
3150
|
}
|
|
3151
|
+
/**
|
|
3152
|
+
* One event's groups with vexp's hook in them once, or with none of it when
|
|
3153
|
+
* `ours` is null (an opt-in switched off). Every Claude Code installer and
|
|
3154
|
+
* removal goes through here, so `isOurs` must answer for a group and for a
|
|
3155
|
+
* hook object alike.
|
|
3156
|
+
*
|
|
3157
|
+
* A group holding only vexp's hooks is replaced where the first one stood,
|
|
3158
|
+
* not moved to the end: the PreToolUse installers took theirs out and pushed
|
|
3159
|
+
* it back last, so with two of them on they swapped places at every setup,
|
|
3160
|
+
* each rewriting settings.json, with a backup, to undo the other; and an old
|
|
3161
|
+
* `bash` entry went to the end of the list on its way to `sh`.
|
|
3162
|
+
*
|
|
3163
|
+
* A group that also holds someone else's hook keeps it. Claude Code's /hooks
|
|
3164
|
+
* menu adds a hook to the group of a matcher that already exists, so a user
|
|
3165
|
+
* who hooks Agent|Task, Bash or the prompt after setup lands inside vexp's
|
|
3166
|
+
* group, and replacing the group whole deleted that hook at the next setup,
|
|
3167
|
+
* and from the extension at every VS Code start (review, 2026-10-24). In such
|
|
3168
|
+
* a group vexp's hook object is put back in its own slot while the group
|
|
3169
|
+
* keeps vexp's matcher; under any other matcher, or when vexp's hook is being
|
|
3170
|
+
* removed, it is taken out and the user's hooks stay where they were, so
|
|
3171
|
+
* vexp's never fires on a matcher vexp did not choose.
|
|
3172
|
+
*
|
|
3173
|
+
* Keep in lockstep with the VS Code extension copy: both rewrite the same
|
|
3174
|
+
* file, and a drift would move a user's hook back and forth between them.
|
|
3175
|
+
*/
|
|
3176
|
+
function placeVexpHook(existing, isOurs, ours) {
|
|
3177
|
+
const placed = [];
|
|
3178
|
+
let done = ours === null;
|
|
3179
|
+
for (const h of existing) {
|
|
3180
|
+
if (!isOurs(h)) {
|
|
3181
|
+
placed.push(h);
|
|
3182
|
+
continue;
|
|
3183
|
+
}
|
|
3184
|
+
const entry = h;
|
|
3185
|
+
const inner = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
3186
|
+
const theirs = inner.filter((x) => !isOurs(x));
|
|
3187
|
+
if (theirs.length === 0) {
|
|
3188
|
+
if (!done)
|
|
3189
|
+
placed.push(ours);
|
|
3190
|
+
done = true;
|
|
3191
|
+
}
|
|
3192
|
+
else if (!done && ours !== null && entry.matcher === ours.matcher && theirs.length < inner.length) {
|
|
3193
|
+
const at = inner.findIndex(isOurs);
|
|
3194
|
+
placed.push({
|
|
3195
|
+
...entry,
|
|
3196
|
+
hooks: inner.flatMap((x, i) => (i === at ? ours.hooks : isOurs(x) ? [] : [x])),
|
|
3197
|
+
});
|
|
3198
|
+
done = true;
|
|
3199
|
+
}
|
|
3200
|
+
else {
|
|
3201
|
+
placed.push({ ...entry, hooks: theirs });
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
if (!done)
|
|
3205
|
+
placed.push(ours);
|
|
3206
|
+
return placed;
|
|
3207
|
+
}
|
|
3141
3208
|
/**
|
|
3142
3209
|
* Horizon F2a: install the Stop-hook verification gate for Claude Code.
|
|
3143
3210
|
* Same fail-open family as the hint hook: the Rust `stop-gate` command
|
|
@@ -3172,17 +3239,16 @@ export function installClaudeCodeSessionContext(workspaceRoot, binaryPath) {
|
|
|
3172
3239
|
const existing = Array.isArray(hooks.SessionStart)
|
|
3173
3240
|
? hooks.SessionStart
|
|
3174
3241
|
: [];
|
|
3175
|
-
const
|
|
3176
|
-
filtered.push({
|
|
3242
|
+
const placed = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-restore"), {
|
|
3177
3243
|
hooks: [
|
|
3178
3244
|
{
|
|
3179
3245
|
type: "command",
|
|
3180
|
-
command: '
|
|
3246
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-restore.sh"',
|
|
3181
3247
|
timeout: 5,
|
|
3182
3248
|
},
|
|
3183
3249
|
],
|
|
3184
3250
|
});
|
|
3185
|
-
const merged = { ...hooks, SessionStart:
|
|
3251
|
+
const merged = { ...hooks, SessionStart: placed };
|
|
3186
3252
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3187
3253
|
if (scriptIdentical && settingsIdentical)
|
|
3188
3254
|
return null;
|
|
@@ -3229,19 +3295,18 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
|
|
|
3229
3295
|
const settings = read.data;
|
|
3230
3296
|
const hooks = (settings.hooks ?? {});
|
|
3231
3297
|
const existing = Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
filtered.push({
|
|
3298
|
+
// Cross-OS shape as everywhere: sh + QUOTED path; timeout in SECONDS.
|
|
3299
|
+
const filtered = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-verify"), gateOn
|
|
3300
|
+
? {
|
|
3236
3301
|
hooks: [
|
|
3237
3302
|
{
|
|
3238
3303
|
type: "command",
|
|
3239
|
-
command: '
|
|
3304
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-verify.sh"',
|
|
3240
3305
|
timeout: 15,
|
|
3241
3306
|
},
|
|
3242
3307
|
],
|
|
3243
|
-
}
|
|
3244
|
-
|
|
3308
|
+
}
|
|
3309
|
+
: null);
|
|
3245
3310
|
const merged = { ...hooks, Stop: filtered };
|
|
3246
3311
|
if (filtered.length === 0)
|
|
3247
3312
|
delete merged.Stop;
|
|
@@ -3285,13 +3350,9 @@ function isVexpHintHookEntry(h) {
|
|
|
3285
3350
|
* opt-in applies to DENY hooks, which add a failure mode; this one cannot
|
|
3286
3351
|
* block anything).
|
|
3287
3352
|
*/
|
|
3288
|
-
/** Does this PreToolUse entry belong to us? */
|
|
3353
|
+
/** Does this PreToolUse entry, or hook object in one, belong to us? Both, for placeVexpHook. */
|
|
3289
3354
|
function isVexpSearchHookEntry(h) {
|
|
3290
|
-
|
|
3291
|
-
return false;
|
|
3292
|
-
const hks = h.hooks;
|
|
3293
|
-
return Array.isArray(hks) && hks.some((hook) => typeof hook?.command === "string" &&
|
|
3294
|
-
hook.command.includes("vexp-search"));
|
|
3355
|
+
return isVexpHookEntry(h, "vexp-search");
|
|
3295
3356
|
}
|
|
3296
3357
|
/**
|
|
3297
3358
|
* v4 M1: install the search answer as a PreToolUse hook on Bash.
|
|
@@ -3342,8 +3403,8 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
|
|
|
3342
3403
|
const settings = read.data;
|
|
3343
3404
|
const hooks = (settings.hooks ?? {});
|
|
3344
3405
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3345
|
-
const filtered = existing
|
|
3346
|
-
if (filtered
|
|
3406
|
+
const filtered = placeVexpHook(existing, (h) => /vexp-(read-hint|bash-cap)/.test(JSON.stringify(h ?? "")), null);
|
|
3407
|
+
if (JSON.stringify(filtered) === JSON.stringify(existing))
|
|
3347
3408
|
return;
|
|
3348
3409
|
const merged = { ...hooks, PreToolUse: filtered };
|
|
3349
3410
|
if (filtered.length === 0)
|
|
@@ -3369,20 +3430,20 @@ function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher,
|
|
|
3369
3430
|
const settings = read.data;
|
|
3370
3431
|
const hooks = (settings.hooks ?? {});
|
|
3371
3432
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3372
|
-
const filtered = existing.filter((h) => !JSON.stringify(h).includes(`vexp-${name}`));
|
|
3373
3433
|
// Shell form with a quoted path: exec form cannot run a .sh on Windows, and
|
|
3374
|
-
// an unquoted path word-splits on "C:\\Program Files" (c4a0b9e).
|
|
3375
|
-
|
|
3434
|
+
// an unquoted path word-splits on "C:\\Program Files" (c4a0b9e). In place,
|
|
3435
|
+
// beside the other PreToolUse installers (placeVexpHook).
|
|
3436
|
+
const placed = placeVexpHook(existing, (h) => JSON.stringify(h ?? "").includes(`vexp-${name}`), {
|
|
3376
3437
|
matcher,
|
|
3377
3438
|
hooks: [
|
|
3378
3439
|
{
|
|
3379
3440
|
type: "command",
|
|
3380
|
-
command: `
|
|
3441
|
+
command: `sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-${name}.sh"`,
|
|
3381
3442
|
timeout: 10,
|
|
3382
3443
|
},
|
|
3383
3444
|
],
|
|
3384
3445
|
});
|
|
3385
|
-
const merged = { ...hooks, PreToolUse:
|
|
3446
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3386
3447
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3387
3448
|
if (scriptIdentical && settingsIdentical)
|
|
3388
3449
|
return null;
|
|
@@ -3415,21 +3476,21 @@ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
|
|
|
3415
3476
|
const settings = read.data;
|
|
3416
3477
|
const hooks = (settings.hooks ?? {});
|
|
3417
3478
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3418
|
-
const filtered = existing.filter((h) => !isVexpSearchHookEntry(h));
|
|
3419
3479
|
// Same shell-form-with-quoted-path shape as every other hook we write: exec
|
|
3420
3480
|
// form cannot run a .sh on Windows, and an unquoted path word-splits on
|
|
3421
|
-
// "C:\Program Files" (both learned the hard way, c4a0b9e).
|
|
3422
|
-
|
|
3481
|
+
// "C:\Program Files" (both learned the hard way, c4a0b9e). In place,
|
|
3482
|
+
// beside the other PreToolUse installers (placeVexpHook).
|
|
3483
|
+
const placed = placeVexpHook(existing, isVexpSearchHookEntry, {
|
|
3423
3484
|
matcher: "Bash",
|
|
3424
3485
|
hooks: [
|
|
3425
3486
|
{
|
|
3426
3487
|
type: "command",
|
|
3427
|
-
command: '
|
|
3488
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
|
|
3428
3489
|
timeout: 10,
|
|
3429
3490
|
},
|
|
3430
3491
|
],
|
|
3431
3492
|
});
|
|
3432
|
-
const merged = { ...hooks, PreToolUse:
|
|
3493
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3433
3494
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3434
3495
|
if (scriptIdentical && settingsIdentical)
|
|
3435
3496
|
return null;
|
|
@@ -3490,14 +3551,13 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3490
3551
|
const existing = Array.isArray(hooks.UserPromptSubmit)
|
|
3491
3552
|
? hooks.UserPromptSubmit
|
|
3492
3553
|
: [];
|
|
3493
|
-
|
|
3494
|
-
// Same cross-OS shape as the guard entry: bash prefix + QUOTED path
|
|
3554
|
+
// Same cross-OS shape as the guard entry: sh prefix + QUOTED path
|
|
3495
3555
|
// (spaces-safe on unix, Git Bash on Windows). timeout is SECONDS.
|
|
3496
|
-
filtered
|
|
3556
|
+
const filtered = placeVexpHook(existing, isVexpHintHookEntry, {
|
|
3497
3557
|
hooks: [
|
|
3498
3558
|
{
|
|
3499
3559
|
type: "command",
|
|
3500
|
-
command: '
|
|
3560
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-hint.sh"',
|
|
3501
3561
|
// The prompt-hint client's budget ceiling (HINT_BUDGET_CEILING_MS,
|
|
3502
3562
|
// main.rs) stays under these 5 s: past them the agent kills the
|
|
3503
3563
|
// hook while the daemon still believes the client is waiting.
|
|
@@ -3512,19 +3572,18 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3512
3572
|
const existingPost = Array.isArray(hooks.PostToolUse)
|
|
3513
3573
|
? hooks.PostToolUse
|
|
3514
3574
|
: [];
|
|
3515
|
-
const filteredPost = existingPost
|
|
3516
|
-
|
|
3517
|
-
filteredPost.push({
|
|
3575
|
+
const filteredPost = placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
|
|
3576
|
+
? {
|
|
3518
3577
|
matcher: "Edit|Write|NotebookEdit",
|
|
3519
3578
|
hooks: [
|
|
3520
3579
|
{
|
|
3521
3580
|
type: "command",
|
|
3522
|
-
command: '
|
|
3581
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-edit-hint.sh"',
|
|
3523
3582
|
timeout: 5,
|
|
3524
3583
|
},
|
|
3525
3584
|
],
|
|
3526
|
-
}
|
|
3527
|
-
|
|
3585
|
+
}
|
|
3586
|
+
: null);
|
|
3528
3587
|
const merged = {
|
|
3529
3588
|
...hooks,
|
|
3530
3589
|
UserPromptSubmit: filtered,
|
|
@@ -3545,6 +3604,65 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3545
3604
|
}
|
|
3546
3605
|
return existed ? "updated" : "created";
|
|
3547
3606
|
}
|
|
3607
|
+
function isVexpSubagentHookEntry(h) {
|
|
3608
|
+
return JSON.stringify(h ?? "").includes("vexp-subagent");
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* The subagent orientation hook (see VEXP_SUBAGENT_HOOK): PreToolUse on
|
|
3612
|
+
* Agent, and on Task, the tool's old name, which Claude Code still matches.
|
|
3613
|
+
* Default-on like the prompt hook and for the same reason: it cannot block
|
|
3614
|
+
* (the script always exits 0 and sets no permissionDecision) and it adds no
|
|
3615
|
+
* turn, only text in the subagent's first message. Installed wherever the
|
|
3616
|
+
* prompt hook is. Keep in lockstep with the VS Code extension copy: same
|
|
3617
|
+
* script, same entry.
|
|
3618
|
+
*
|
|
3619
|
+
* The script and its registration go together: a settings.json we cannot
|
|
3620
|
+
* read stops both, where the older installers still write a script nothing
|
|
3621
|
+
* runs. vexp's entry is replaced where it stands, and a user's hook in its
|
|
3622
|
+
* Agent|Task group stays (placeVexpHook).
|
|
3623
|
+
*/
|
|
3624
|
+
export function installClaudeCodeSubagentHook(workspaceRoot, binaryPath) {
|
|
3625
|
+
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
3626
|
+
const hookPath = path.join(hookDir, "vexp-subagent.sh");
|
|
3627
|
+
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
3628
|
+
const script = vexpSubagentHookScript(binaryPath);
|
|
3629
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
3630
|
+
if (!read.ok) {
|
|
3631
|
+
warnUnparseable(settingsPath);
|
|
3632
|
+
return null;
|
|
3633
|
+
}
|
|
3634
|
+
fs.mkdirSync(hookDir, { recursive: true });
|
|
3635
|
+
const existed = fs.existsSync(hookPath);
|
|
3636
|
+
const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
3637
|
+
if (!scriptIdentical)
|
|
3638
|
+
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3639
|
+
const settings = read.data;
|
|
3640
|
+
const hooks = (settings.hooks ?? {});
|
|
3641
|
+
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3642
|
+
// Same cross-OS shape as every other hook: sh prefix + QUOTED path.
|
|
3643
|
+
// timeout is SECONDS, the prompt hook's: the same daemon call answers both.
|
|
3644
|
+
const ours = {
|
|
3645
|
+
matcher: "Agent|Task",
|
|
3646
|
+
hooks: [
|
|
3647
|
+
{
|
|
3648
|
+
type: "command",
|
|
3649
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-subagent.sh"',
|
|
3650
|
+
timeout: 5,
|
|
3651
|
+
},
|
|
3652
|
+
],
|
|
3653
|
+
};
|
|
3654
|
+
const merged = { ...hooks, PreToolUse: placeVexpHook(existing, isVexpSubagentHookEntry, ours) };
|
|
3655
|
+
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3656
|
+
if (scriptIdentical && settingsIdentical)
|
|
3657
|
+
return null;
|
|
3658
|
+
settings.hooks = merged;
|
|
3659
|
+
if (!settingsIdentical) {
|
|
3660
|
+
if (read.existed)
|
|
3661
|
+
backupConfig(settingsPath);
|
|
3662
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
3663
|
+
}
|
|
3664
|
+
return existed ? "updated" : "created";
|
|
3665
|
+
}
|
|
3548
3666
|
/**
|
|
3549
3667
|
* opencode/Kilo hint plugin (2.4.0): default-ON, non-blocking, fail-open —
|
|
3550
3668
|
* unlike the deny guard (strict-only), this cannot break a session; it only
|
|
@@ -3651,7 +3769,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3651
3769
|
? codexHooks.UserPromptSubmit
|
|
3652
3770
|
: [];
|
|
3653
3771
|
const existing = [...wrapped, ...legacyTopLevel];
|
|
3654
|
-
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
|
|
3772
|
+
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution. And
|
|
3773
|
+
// `bash`, not the `sh` of the Claude Code entries: Codex hashes its trust
|
|
3774
|
+
// record over this line (codex-trust.ts codexHookHash), so a change would
|
|
3775
|
+
// make every Codex user approve the hook again. The script it runs is
|
|
3776
|
+
// POSIX sh all the same, and a new script needs no new approval.
|
|
3655
3777
|
const ours = {
|
|
3656
3778
|
hooks: [
|
|
3657
3779
|
{
|
|
@@ -4054,7 +4176,10 @@ export function uninstallCursorHook(workspaceRoot) {
|
|
|
4054
4176
|
* So this keeps only the three things a schema cannot carry:
|
|
4055
4177
|
*
|
|
4056
4178
|
* 1. WHEN to call, and when not to - the schema says what a tool returns,
|
|
4057
|
-
* not whether this task wants it.
|
|
4179
|
+
* not whether this task wants it. Only run_pipeline is skippable: every
|
|
4180
|
+
* seeded prompt, a subagent's included, names its files, and the old
|
|
4181
|
+
* "SKIP vexp" then told the agent to drop verify_done and get_skeleton
|
|
4182
|
+
* along with it.
|
|
4058
4183
|
* 2. The two guardrails that stop wasted calls: text sweeps belong to
|
|
4059
4184
|
* native search, and the index covers repo source only (logs, dist/,
|
|
4060
4185
|
* node_modules are not in it - read those directly).
|
|
@@ -4064,7 +4189,7 @@ export function uninstallCursorHook(workspaceRoot) {
|
|
|
4064
4189
|
* mcp_stdio.rs for the same argument applied one layer down.
|
|
4065
4190
|
*/
|
|
4066
4191
|
const MANDATE_CORE = `### Context strategy: call run_pipeline ONCE at task start
|
|
4067
|
-
If the task already names the files/symbols to touch,
|
|
4192
|
+
If the task already names the files/symbols to touch, skip run_pipeline. Otherwise one
|
|
4068
4193
|
\`run_pipeline({ "task": "..." })\` returns ranked pivot files with line ranges and
|
|
4069
4194
|
blast radius. Do NOT open files one by one to find your way around - every extra
|
|
4070
4195
|
tool call costs a turn. Call it again ONLY when the task moves to a new area.
|
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,23 @@ program
|
|
|
33
33
|
.description("Vexp — Graph-RAG context engine for AI coding agents")
|
|
34
34
|
.version(CLI_VERSION)
|
|
35
35
|
.option("-i, --trace", "Verbose trace mode: print every step with timing (IPs/tokens redacted)");
|
|
36
|
+
// The idle stop was only to be found by reading the binary (field report,
|
|
37
|
+
// 2026-10-17). Said under the root help and under the commands that list
|
|
38
|
+
// and stop daemons.
|
|
39
|
+
const IDLE_STOP_HELP = `
|
|
40
|
+
Daemons:
|
|
41
|
+
vexp runs one daemon per project. It runs until it is stopped (vexp stop, vexp stop --all),
|
|
42
|
+
or, with this setting, until it has gone unused that many minutes; the next tool call or
|
|
43
|
+
prompt starts it again:
|
|
44
|
+
|
|
45
|
+
# ~/.vexp/config.toml
|
|
46
|
+
[daemon]
|
|
47
|
+
idle_timeout_minutes = 30
|
|
48
|
+
|
|
49
|
+
VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. A daemon an editor window started stops
|
|
50
|
+
with that window instead. vexp daemons lists them; vexp doctor says whether the setting is on.
|
|
51
|
+
`;
|
|
52
|
+
program.addHelpText("after", IDLE_STOP_HELP);
|
|
36
53
|
// Propagate -i / --trace to spawned children via env var so subprocess
|
|
37
54
|
// (daemon, MCP, serve) inherits the trace flag.
|
|
38
55
|
if (isTraceEnabled()) {
|
|
@@ -599,6 +616,7 @@ program
|
|
|
599
616
|
.command("daemons")
|
|
600
617
|
.description("List every vexp daemon on this machine: which workspace holds each concurrent-workspace slot, version, process id, memory, index size, uptime")
|
|
601
618
|
.option("--json", "Emit machine-readable JSON")
|
|
619
|
+
.addHelpText("after", IDLE_STOP_HELP)
|
|
602
620
|
.action(async (opts) => {
|
|
603
621
|
const binaryPath = ensureBinary();
|
|
604
622
|
const args = ["daemons"];
|
|
@@ -673,6 +691,7 @@ program
|
|
|
673
691
|
.command("stop [workspace]")
|
|
674
692
|
.description("Stop the vexp daemon of a workspace (default: current directory), or with --all every daemon on this machine. Accepts any path shown by the concurrent-workspace cap message")
|
|
675
693
|
.option("--all", "Stop every live vexp daemon on this machine, one after the other")
|
|
694
|
+
.addHelpText("after", IDLE_STOP_HELP)
|
|
676
695
|
.action(async (workspace, opts) => {
|
|
677
696
|
if (opts.all && workspace !== undefined) {
|
|
678
697
|
console.error(`--all stops every daemon on this machine and takes no workspace: run 'vexp stop --all', or 'vexp stop "${workspace}"' for that one.`);
|
|
@@ -832,6 +851,7 @@ program
|
|
|
832
851
|
.command("daemon-cmd <action>")
|
|
833
852
|
.description("Manage the vexp daemon (start, stop, restart, status, logs)")
|
|
834
853
|
.option("--follow", "Follow log output (with 'logs' action)")
|
|
854
|
+
.addHelpText("after", IDLE_STOP_HELP)
|
|
835
855
|
.action(async (action, opts) => {
|
|
836
856
|
const binaryPath = ensureBinary();
|
|
837
857
|
const args = ["daemon-cmd", action];
|