vexp-cli 3.3.7 → 3.3.9
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 -1
- package/dist/agent-config.js +196 -54
- package/dist/cli.js +2 -1
- package/dist/daemon-upgrade.js +19 -6
- package/dist/doctor.js +174 -13
- package/dist/forget.js +1 -0
- package/dist/hook-template.js +56 -11
- package/dist/serve.js +47 -1
- package/mcp/mcp-server.cjs +46 -46
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ vexp runs one small daemon per project, started by your agent's first call. By d
|
|
|
69
69
|
idle_timeout_minutes = 30
|
|
70
70
|
```
|
|
71
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.
|
|
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. Unset, a daemon an agent's session started (any agent) stops after 30 minutes without use and frees its slot of the plan's daemon cap; `idle_timeout_minutes = 0` turns that off, and `VEXP_NO_AUTOSTART=1` keeps sessions from starting one. `vexp daemons` lists what is running, with each daemon's memory, and `vexp doctor` shows whether the setting is on.
|
|
73
73
|
|
|
74
74
|
## Supported agents
|
|
75
75
|
|
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);
|
|
@@ -2449,6 +2452,12 @@ function isUserManagedCodexSection(section) {
|
|
|
2449
2452
|
* them. `pinTo` is `vexp use --pin`, on purpose. The http section keeps its
|
|
2450
2453
|
* project: the URL routes to one workspace's daemon.
|
|
2451
2454
|
*
|
|
2455
|
+
* `vexp-core mcp` starts the checkout's shared daemon when none runs, as the
|
|
2456
|
+
* Node server does. Unused for 30 minutes it stops and frees its slot of the
|
|
2457
|
+
* plan's daemon cap (vexp-core idle.rs; `idle_timeout_minutes` overrides it).
|
|
2458
|
+
* VEXP_NO_AUTOSTART=1, or a cap already full, leaves the session serving
|
|
2459
|
+
* in-process and taking no slot.
|
|
2460
|
+
*
|
|
2452
2461
|
* BYTE-IDENTICAL with the extension's buildCodexSection, marker lines
|
|
2453
2462
|
* included, or the two rewrite this machine-wide file in turn. */
|
|
2454
2463
|
function buildCodexSection(opts) {
|
|
@@ -2505,10 +2514,21 @@ Authorization = "Bearer ${opts.token}"
|
|
|
2505
2514
|
* buildCodexSection), so one user entry serves every project.
|
|
2506
2515
|
*
|
|
2507
2516
|
* Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
|
|
2508
|
-
* without VS Code
|
|
2509
|
-
*
|
|
2517
|
+
* without VS Code: `vexp-core mcp` proxies to the project's daemon, starting it
|
|
2518
|
+
* when none runs as the Node MCP server does, so every session on a checkout
|
|
2519
|
+
* shares one daemon. It runs an embedded in-process index where it may not
|
|
2520
|
+
* start one (VEXP_NO_AUTOSTART=1, the daily limit), where the plan's daemon cap
|
|
2521
|
+
* refuses it, or where it does not answer in time. Override with the
|
|
2510
2522
|
* VEXP_CODEX_TRANSPORT env var ("direct" | "http").
|
|
2511
2523
|
*
|
|
2524
|
+
* Every agent configured with `vexp-core mcp` directly runs that server and
|
|
2525
|
+
* starts the daemon the same way: Cline, Continue, ZCode and the entries
|
|
2526
|
+
* written where no Node runtime is found. In an editor with the vexp
|
|
2527
|
+
* extension, such a server can start the daemon before the extension does.
|
|
2528
|
+
* The extension then adopts it as a daemon it does not own, and the rule
|
|
2529
|
+
* that ends a window's daemon with its window does not apply to it: it runs
|
|
2530
|
+
* until `vexp stop`, the idle stop (`idle_timeout_minutes`) or a reboot.
|
|
2531
|
+
*
|
|
2512
2532
|
* `pin` is `vexp use <project> --pin`: the one way to name a project in the entry,
|
|
2513
2533
|
* kept by every later setup until `vexp use` changes it. `unpin` is
|
|
2514
2534
|
* `vexp use --unpin`. [mcp_servers.vexp.tools.*] tables (Codex's per-tool
|
|
@@ -3045,8 +3065,9 @@ export function uninstallClaudeCodeHook(workspaceRoot) {
|
|
|
3045
3065
|
const settings = read.data;
|
|
3046
3066
|
const hooks = (settings.hooks ?? {});
|
|
3047
3067
|
const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3048
|
-
|
|
3049
|
-
|
|
3068
|
+
// vexp's hooks out, a user's hook in their group kept (placeVexpHook).
|
|
3069
|
+
const filtered = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, null);
|
|
3070
|
+
if (JSON.stringify(filtered) !== JSON.stringify(existingPreToolUse)) {
|
|
3050
3071
|
if (filtered.length > 0)
|
|
3051
3072
|
hooks.PreToolUse = filtered;
|
|
3052
3073
|
else
|
|
@@ -3087,9 +3108,10 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3087
3108
|
const settings = read.data;
|
|
3088
3109
|
const hooks = (settings.hooks ?? {});
|
|
3089
3110
|
const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3090
|
-
// Aggressively remove ALL vexp-related hook entries (old format, stale
|
|
3091
|
-
|
|
3092
|
-
//
|
|
3111
|
+
// Aggressively remove ALL vexp-related hook entries (old format, stale
|
|
3112
|
+
// matchers, malformed); ours goes where the first of them stood, and a
|
|
3113
|
+
// user's hook in that group stays (placeVexpHook).
|
|
3114
|
+
// Shell form with an explicit `sh` prefix and a QUOTED path — the one
|
|
3093
3115
|
// shape that works on every OS Claude Code supports:
|
|
3094
3116
|
// - the quotes fix the historical spaces bug (unquoted
|
|
3095
3117
|
// `$CLAUDE_PROJECT_DIR/...` word-split and the guard never ran);
|
|
@@ -3097,15 +3119,20 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3097
3119
|
// Windows entirely: the OS cannot spawn a `.sh` without a shell, the
|
|
3098
3120
|
// hook errored, failed open, and enforced nothing (field report) —
|
|
3099
3121
|
// Claude Code on Windows runs shell-form hooks through Git Bash, so
|
|
3100
|
-
// `
|
|
3122
|
+
// `sh "$VAR"` works there and on unix alike.
|
|
3123
|
+
// - `sh`, not the `bash` of earlier entries: Alpine (BusyBox) has no
|
|
3124
|
+
// bash, so every vexp hook failed there. On Windows Claude Code puts
|
|
3125
|
+
// Git Bash's folder first on PATH, and Git for Windows ships sh.exe
|
|
3126
|
+
// beside bash.exe, so `sh` resolves there and never to WSL's bash.exe.
|
|
3127
|
+
// An old `bash` entry is matched by its script name and replaced.
|
|
3101
3128
|
// `timeout` is SECONDS (default 600), not ms: 3000 once configured a
|
|
3102
3129
|
// 50-minute hook timeout.
|
|
3103
|
-
|
|
3130
|
+
const placed = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, {
|
|
3104
3131
|
matcher: "Grep|Glob|Regex",
|
|
3105
3132
|
hooks: [
|
|
3106
3133
|
{
|
|
3107
3134
|
type: "command",
|
|
3108
|
-
command: '
|
|
3135
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh"',
|
|
3109
3136
|
timeout: 5,
|
|
3110
3137
|
},
|
|
3111
3138
|
],
|
|
@@ -3113,7 +3140,7 @@ export function installClaudeCodeHook(workspaceRoot) {
|
|
|
3113
3140
|
// Idempotence check AFTER the merge is computed: skip the write (and the
|
|
3114
3141
|
// backup) only when settings already contain exactly the entry we would
|
|
3115
3142
|
// write AND the script was already current.
|
|
3116
|
-
const merged = { ...hooks, PreToolUse:
|
|
3143
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3117
3144
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3118
3145
|
if (scriptIdentical && settingsIdentical)
|
|
3119
3146
|
return null;
|
|
@@ -3138,6 +3165,63 @@ function isVexpHookEntry(h, needle) {
|
|
|
3138
3165
|
return hook.command?.includes(needle) ?? false;
|
|
3139
3166
|
});
|
|
3140
3167
|
}
|
|
3168
|
+
/**
|
|
3169
|
+
* One event's groups with vexp's hook in them once, or with none of it when
|
|
3170
|
+
* `ours` is null (an opt-in switched off). Every Claude Code installer and
|
|
3171
|
+
* removal goes through here, so `isOurs` must answer for a group and for a
|
|
3172
|
+
* hook object alike.
|
|
3173
|
+
*
|
|
3174
|
+
* A group holding only vexp's hooks is replaced where the first one stood,
|
|
3175
|
+
* not moved to the end: the PreToolUse installers took theirs out and pushed
|
|
3176
|
+
* it back last, so with two of them on they swapped places at every setup,
|
|
3177
|
+
* each rewriting settings.json, with a backup, to undo the other; and an old
|
|
3178
|
+
* `bash` entry went to the end of the list on its way to `sh`.
|
|
3179
|
+
*
|
|
3180
|
+
* A group that also holds someone else's hook keeps it. Claude Code's /hooks
|
|
3181
|
+
* menu adds a hook to the group of a matcher that already exists, so a user
|
|
3182
|
+
* who hooks Agent|Task, Bash or the prompt after setup lands inside vexp's
|
|
3183
|
+
* group, and replacing the group whole deleted that hook at the next setup,
|
|
3184
|
+
* and from the extension at every VS Code start (review, 2026-10-24). In such
|
|
3185
|
+
* a group vexp's hook object is put back in its own slot while the group
|
|
3186
|
+
* keeps vexp's matcher; under any other matcher, or when vexp's hook is being
|
|
3187
|
+
* removed, it is taken out and the user's hooks stay where they were, so
|
|
3188
|
+
* vexp's never fires on a matcher vexp did not choose.
|
|
3189
|
+
*
|
|
3190
|
+
* Keep in lockstep with the VS Code extension copy: both rewrite the same
|
|
3191
|
+
* file, and a drift would move a user's hook back and forth between them.
|
|
3192
|
+
*/
|
|
3193
|
+
function placeVexpHook(existing, isOurs, ours) {
|
|
3194
|
+
const placed = [];
|
|
3195
|
+
let done = ours === null;
|
|
3196
|
+
for (const h of existing) {
|
|
3197
|
+
if (!isOurs(h)) {
|
|
3198
|
+
placed.push(h);
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
const entry = h;
|
|
3202
|
+
const inner = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
3203
|
+
const theirs = inner.filter((x) => !isOurs(x));
|
|
3204
|
+
if (theirs.length === 0) {
|
|
3205
|
+
if (!done)
|
|
3206
|
+
placed.push(ours);
|
|
3207
|
+
done = true;
|
|
3208
|
+
}
|
|
3209
|
+
else if (!done && ours !== null && entry.matcher === ours.matcher && theirs.length < inner.length) {
|
|
3210
|
+
const at = inner.findIndex(isOurs);
|
|
3211
|
+
placed.push({
|
|
3212
|
+
...entry,
|
|
3213
|
+
hooks: inner.flatMap((x, i) => (i === at ? ours.hooks : isOurs(x) ? [] : [x])),
|
|
3214
|
+
});
|
|
3215
|
+
done = true;
|
|
3216
|
+
}
|
|
3217
|
+
else {
|
|
3218
|
+
placed.push({ ...entry, hooks: theirs });
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
if (!done)
|
|
3222
|
+
placed.push(ours);
|
|
3223
|
+
return placed;
|
|
3224
|
+
}
|
|
3141
3225
|
/**
|
|
3142
3226
|
* Horizon F2a: install the Stop-hook verification gate for Claude Code.
|
|
3143
3227
|
* Same fail-open family as the hint hook: the Rust `stop-gate` command
|
|
@@ -3172,17 +3256,16 @@ export function installClaudeCodeSessionContext(workspaceRoot, binaryPath) {
|
|
|
3172
3256
|
const existing = Array.isArray(hooks.SessionStart)
|
|
3173
3257
|
? hooks.SessionStart
|
|
3174
3258
|
: [];
|
|
3175
|
-
const
|
|
3176
|
-
filtered.push({
|
|
3259
|
+
const placed = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-restore"), {
|
|
3177
3260
|
hooks: [
|
|
3178
3261
|
{
|
|
3179
3262
|
type: "command",
|
|
3180
|
-
command: '
|
|
3263
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-restore.sh"',
|
|
3181
3264
|
timeout: 5,
|
|
3182
3265
|
},
|
|
3183
3266
|
],
|
|
3184
3267
|
});
|
|
3185
|
-
const merged = { ...hooks, SessionStart:
|
|
3268
|
+
const merged = { ...hooks, SessionStart: placed };
|
|
3186
3269
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3187
3270
|
if (scriptIdentical && settingsIdentical)
|
|
3188
3271
|
return null;
|
|
@@ -3229,19 +3312,18 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
|
|
|
3229
3312
|
const settings = read.data;
|
|
3230
3313
|
const hooks = (settings.hooks ?? {});
|
|
3231
3314
|
const existing = Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
filtered.push({
|
|
3315
|
+
// Cross-OS shape as everywhere: sh + QUOTED path; timeout in SECONDS.
|
|
3316
|
+
const filtered = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-verify"), gateOn
|
|
3317
|
+
? {
|
|
3236
3318
|
hooks: [
|
|
3237
3319
|
{
|
|
3238
3320
|
type: "command",
|
|
3239
|
-
command: '
|
|
3321
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-verify.sh"',
|
|
3240
3322
|
timeout: 15,
|
|
3241
3323
|
},
|
|
3242
3324
|
],
|
|
3243
|
-
}
|
|
3244
|
-
|
|
3325
|
+
}
|
|
3326
|
+
: null);
|
|
3245
3327
|
const merged = { ...hooks, Stop: filtered };
|
|
3246
3328
|
if (filtered.length === 0)
|
|
3247
3329
|
delete merged.Stop;
|
|
@@ -3285,13 +3367,9 @@ function isVexpHintHookEntry(h) {
|
|
|
3285
3367
|
* opt-in applies to DENY hooks, which add a failure mode; this one cannot
|
|
3286
3368
|
* block anything).
|
|
3287
3369
|
*/
|
|
3288
|
-
/** Does this PreToolUse entry belong to us? */
|
|
3370
|
+
/** Does this PreToolUse entry, or hook object in one, belong to us? Both, for placeVexpHook. */
|
|
3289
3371
|
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"));
|
|
3372
|
+
return isVexpHookEntry(h, "vexp-search");
|
|
3295
3373
|
}
|
|
3296
3374
|
/**
|
|
3297
3375
|
* v4 M1: install the search answer as a PreToolUse hook on Bash.
|
|
@@ -3342,8 +3420,8 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
|
|
|
3342
3420
|
const settings = read.data;
|
|
3343
3421
|
const hooks = (settings.hooks ?? {});
|
|
3344
3422
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3345
|
-
const filtered = existing
|
|
3346
|
-
if (filtered
|
|
3423
|
+
const filtered = placeVexpHook(existing, (h) => /vexp-(read-hint|bash-cap)/.test(JSON.stringify(h ?? "")), null);
|
|
3424
|
+
if (JSON.stringify(filtered) === JSON.stringify(existing))
|
|
3347
3425
|
return;
|
|
3348
3426
|
const merged = { ...hooks, PreToolUse: filtered };
|
|
3349
3427
|
if (filtered.length === 0)
|
|
@@ -3369,20 +3447,20 @@ function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher,
|
|
|
3369
3447
|
const settings = read.data;
|
|
3370
3448
|
const hooks = (settings.hooks ?? {});
|
|
3371
3449
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3372
|
-
const filtered = existing.filter((h) => !JSON.stringify(h).includes(`vexp-${name}`));
|
|
3373
3450
|
// 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
|
-
|
|
3451
|
+
// an unquoted path word-splits on "C:\\Program Files" (c4a0b9e). In place,
|
|
3452
|
+
// beside the other PreToolUse installers (placeVexpHook).
|
|
3453
|
+
const placed = placeVexpHook(existing, (h) => JSON.stringify(h ?? "").includes(`vexp-${name}`), {
|
|
3376
3454
|
matcher,
|
|
3377
3455
|
hooks: [
|
|
3378
3456
|
{
|
|
3379
3457
|
type: "command",
|
|
3380
|
-
command: `
|
|
3458
|
+
command: `sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-${name}.sh"`,
|
|
3381
3459
|
timeout: 10,
|
|
3382
3460
|
},
|
|
3383
3461
|
],
|
|
3384
3462
|
});
|
|
3385
|
-
const merged = { ...hooks, PreToolUse:
|
|
3463
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3386
3464
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3387
3465
|
if (scriptIdentical && settingsIdentical)
|
|
3388
3466
|
return null;
|
|
@@ -3415,21 +3493,21 @@ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
|
|
|
3415
3493
|
const settings = read.data;
|
|
3416
3494
|
const hooks = (settings.hooks ?? {});
|
|
3417
3495
|
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3418
|
-
const filtered = existing.filter((h) => !isVexpSearchHookEntry(h));
|
|
3419
3496
|
// Same shell-form-with-quoted-path shape as every other hook we write: exec
|
|
3420
3497
|
// 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
|
-
|
|
3498
|
+
// "C:\Program Files" (both learned the hard way, c4a0b9e). In place,
|
|
3499
|
+
// beside the other PreToolUse installers (placeVexpHook).
|
|
3500
|
+
const placed = placeVexpHook(existing, isVexpSearchHookEntry, {
|
|
3423
3501
|
matcher: "Bash",
|
|
3424
3502
|
hooks: [
|
|
3425
3503
|
{
|
|
3426
3504
|
type: "command",
|
|
3427
|
-
command: '
|
|
3505
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
|
|
3428
3506
|
timeout: 10,
|
|
3429
3507
|
},
|
|
3430
3508
|
],
|
|
3431
3509
|
});
|
|
3432
|
-
const merged = { ...hooks, PreToolUse:
|
|
3510
|
+
const merged = { ...hooks, PreToolUse: placed };
|
|
3433
3511
|
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3434
3512
|
if (scriptIdentical && settingsIdentical)
|
|
3435
3513
|
return null;
|
|
@@ -3490,14 +3568,13 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3490
3568
|
const existing = Array.isArray(hooks.UserPromptSubmit)
|
|
3491
3569
|
? hooks.UserPromptSubmit
|
|
3492
3570
|
: [];
|
|
3493
|
-
|
|
3494
|
-
// Same cross-OS shape as the guard entry: bash prefix + QUOTED path
|
|
3571
|
+
// Same cross-OS shape as the guard entry: sh prefix + QUOTED path
|
|
3495
3572
|
// (spaces-safe on unix, Git Bash on Windows). timeout is SECONDS.
|
|
3496
|
-
filtered
|
|
3573
|
+
const filtered = placeVexpHook(existing, isVexpHintHookEntry, {
|
|
3497
3574
|
hooks: [
|
|
3498
3575
|
{
|
|
3499
3576
|
type: "command",
|
|
3500
|
-
command: '
|
|
3577
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-hint.sh"',
|
|
3501
3578
|
// The prompt-hint client's budget ceiling (HINT_BUDGET_CEILING_MS,
|
|
3502
3579
|
// main.rs) stays under these 5 s: past them the agent kills the
|
|
3503
3580
|
// hook while the daemon still believes the client is waiting.
|
|
@@ -3512,19 +3589,18 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3512
3589
|
const existingPost = Array.isArray(hooks.PostToolUse)
|
|
3513
3590
|
? hooks.PostToolUse
|
|
3514
3591
|
: [];
|
|
3515
|
-
const filteredPost = existingPost
|
|
3516
|
-
|
|
3517
|
-
filteredPost.push({
|
|
3592
|
+
const filteredPost = placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
|
|
3593
|
+
? {
|
|
3518
3594
|
matcher: "Edit|Write|NotebookEdit",
|
|
3519
3595
|
hooks: [
|
|
3520
3596
|
{
|
|
3521
3597
|
type: "command",
|
|
3522
|
-
command: '
|
|
3598
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-edit-hint.sh"',
|
|
3523
3599
|
timeout: 5,
|
|
3524
3600
|
},
|
|
3525
3601
|
],
|
|
3526
|
-
}
|
|
3527
|
-
|
|
3602
|
+
}
|
|
3603
|
+
: null);
|
|
3528
3604
|
const merged = {
|
|
3529
3605
|
...hooks,
|
|
3530
3606
|
UserPromptSubmit: filtered,
|
|
@@ -3545,6 +3621,65 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
|
|
|
3545
3621
|
}
|
|
3546
3622
|
return existed ? "updated" : "created";
|
|
3547
3623
|
}
|
|
3624
|
+
function isVexpSubagentHookEntry(h) {
|
|
3625
|
+
return JSON.stringify(h ?? "").includes("vexp-subagent");
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* The subagent orientation hook (see VEXP_SUBAGENT_HOOK): PreToolUse on
|
|
3629
|
+
* Agent, and on Task, the tool's old name, which Claude Code still matches.
|
|
3630
|
+
* Default-on like the prompt hook and for the same reason: it cannot block
|
|
3631
|
+
* (the script always exits 0 and sets no permissionDecision) and it adds no
|
|
3632
|
+
* turn, only text in the subagent's first message. Installed wherever the
|
|
3633
|
+
* prompt hook is. Keep in lockstep with the VS Code extension copy: same
|
|
3634
|
+
* script, same entry.
|
|
3635
|
+
*
|
|
3636
|
+
* The script and its registration go together: a settings.json we cannot
|
|
3637
|
+
* read stops both, where the older installers still write a script nothing
|
|
3638
|
+
* runs. vexp's entry is replaced where it stands, and a user's hook in its
|
|
3639
|
+
* Agent|Task group stays (placeVexpHook).
|
|
3640
|
+
*/
|
|
3641
|
+
export function installClaudeCodeSubagentHook(workspaceRoot, binaryPath) {
|
|
3642
|
+
const hookDir = path.join(workspaceRoot, ".claude", "hooks");
|
|
3643
|
+
const hookPath = path.join(hookDir, "vexp-subagent.sh");
|
|
3644
|
+
const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
|
|
3645
|
+
const script = vexpSubagentHookScript(binaryPath);
|
|
3646
|
+
const read = readJsonConfigSafe(settingsPath);
|
|
3647
|
+
if (!read.ok) {
|
|
3648
|
+
warnUnparseable(settingsPath);
|
|
3649
|
+
return null;
|
|
3650
|
+
}
|
|
3651
|
+
fs.mkdirSync(hookDir, { recursive: true });
|
|
3652
|
+
const existed = fs.existsSync(hookPath);
|
|
3653
|
+
const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
|
|
3654
|
+
if (!scriptIdentical)
|
|
3655
|
+
fs.writeFileSync(hookPath, script, { mode: 0o755 });
|
|
3656
|
+
const settings = read.data;
|
|
3657
|
+
const hooks = (settings.hooks ?? {});
|
|
3658
|
+
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
3659
|
+
// Same cross-OS shape as every other hook: sh prefix + QUOTED path.
|
|
3660
|
+
// timeout is SECONDS, the prompt hook's: the same daemon call answers both.
|
|
3661
|
+
const ours = {
|
|
3662
|
+
matcher: "Agent|Task",
|
|
3663
|
+
hooks: [
|
|
3664
|
+
{
|
|
3665
|
+
type: "command",
|
|
3666
|
+
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-subagent.sh"',
|
|
3667
|
+
timeout: 5,
|
|
3668
|
+
},
|
|
3669
|
+
],
|
|
3670
|
+
};
|
|
3671
|
+
const merged = { ...hooks, PreToolUse: placeVexpHook(existing, isVexpSubagentHookEntry, ours) };
|
|
3672
|
+
const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
|
|
3673
|
+
if (scriptIdentical && settingsIdentical)
|
|
3674
|
+
return null;
|
|
3675
|
+
settings.hooks = merged;
|
|
3676
|
+
if (!settingsIdentical) {
|
|
3677
|
+
if (read.existed)
|
|
3678
|
+
backupConfig(settingsPath);
|
|
3679
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
3680
|
+
}
|
|
3681
|
+
return existed ? "updated" : "created";
|
|
3682
|
+
}
|
|
3548
3683
|
/**
|
|
3549
3684
|
* opencode/Kilo hint plugin (2.4.0): default-ON, non-blocking, fail-open —
|
|
3550
3685
|
* unlike the deny guard (strict-only), this cannot break a session; it only
|
|
@@ -3651,7 +3786,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
3651
3786
|
? codexHooks.UserPromptSubmit
|
|
3652
3787
|
: [];
|
|
3653
3788
|
const existing = [...wrapped, ...legacyTopLevel];
|
|
3654
|
-
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
|
|
3789
|
+
// Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution. And
|
|
3790
|
+
// `bash`, not the `sh` of the Claude Code entries: Codex hashes its trust
|
|
3791
|
+
// record over this line (codex-trust.ts codexHookHash), so a change would
|
|
3792
|
+
// make every Codex user approve the hook again. The script it runs is
|
|
3793
|
+
// POSIX sh all the same, and a new script needs no new approval.
|
|
3655
3794
|
const ours = {
|
|
3656
3795
|
hooks: [
|
|
3657
3796
|
{
|
|
@@ -4054,7 +4193,10 @@ export function uninstallCursorHook(workspaceRoot) {
|
|
|
4054
4193
|
* So this keeps only the three things a schema cannot carry:
|
|
4055
4194
|
*
|
|
4056
4195
|
* 1. WHEN to call, and when not to - the schema says what a tool returns,
|
|
4057
|
-
* not whether this task wants it.
|
|
4196
|
+
* not whether this task wants it. Only run_pipeline is skippable: every
|
|
4197
|
+
* seeded prompt, a subagent's included, names its files, and the old
|
|
4198
|
+
* "SKIP vexp" then told the agent to drop verify_done and get_skeleton
|
|
4199
|
+
* along with it.
|
|
4058
4200
|
* 2. The two guardrails that stop wasted calls: text sweeps belong to
|
|
4059
4201
|
* native search, and the index covers repo source only (logs, dist/,
|
|
4060
4202
|
* node_modules are not in it - read those directly).
|
|
@@ -4064,7 +4206,7 @@ export function uninstallCursorHook(workspaceRoot) {
|
|
|
4064
4206
|
* mcp_stdio.rs for the same argument applied one layer down.
|
|
4065
4207
|
*/
|
|
4066
4208
|
const MANDATE_CORE = `### Context strategy: call run_pipeline ONCE at task start
|
|
4067
|
-
If the task already names the files/symbols to touch,
|
|
4209
|
+
If the task already names the files/symbols to touch, skip run_pipeline. Otherwise one
|
|
4068
4210
|
\`run_pipeline({ "task": "..." })\` returns ranked pivot files with line ranges and
|
|
4069
4211
|
blast radius. Do NOT open files one by one to find your way around - every extra
|
|
4070
4212
|
tool call costs a turn. Call it again ONLY when the task moves to a new area.
|
package/dist/cli.js
CHANGED
|
@@ -46,7 +46,8 @@ Daemons:
|
|
|
46
46
|
[daemon]
|
|
47
47
|
idle_timeout_minutes = 30
|
|
48
48
|
|
|
49
|
-
VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon.
|
|
49
|
+
VEXP_IDLE_TIMEOUT_MINUTES sets it for one daemon. Unset, a daemon an agent's session started
|
|
50
|
+
stops after 30 minutes without use (0 turns that off). A daemon an editor window started stops
|
|
50
51
|
with that window instead. vexp daemons lists them; vexp doctor says whether the setting is on.
|
|
51
52
|
`;
|
|
52
53
|
program.addHelpText("after", IDLE_STOP_HELP);
|
package/dist/daemon-upgrade.js
CHANGED
|
@@ -187,8 +187,13 @@ export function parseEngineVersion(stdout) {
|
|
|
187
187
|
return last ? last : undefined;
|
|
188
188
|
}
|
|
189
189
|
/**
|
|
190
|
-
* The folder `daemon-cmd` works on when run in `dir
|
|
191
|
-
* `.git` at or above it,
|
|
190
|
+
* The folder `daemon-cmd` works on when run in `dir` (daemon_cmd_root in
|
|
191
|
+
* vexp-core): the nearest one with a `.git` at or above it, unless that one
|
|
192
|
+
* was never set up for vexp and a folder between it and `dir` was, which
|
|
193
|
+
* is then the nearest such folder; with no `.git` above, `dir` itself. Set
|
|
194
|
+
* up is the engine's is_workspace_marker: one of its consent files in the
|
|
195
|
+
* folder's .vexp/, and neither the home directory nor a filesystem root
|
|
196
|
+
* (forbidden_workspace_reason refuses both).
|
|
192
197
|
*/
|
|
193
198
|
export function daemonCmdRoot(dir) {
|
|
194
199
|
let start = path.resolve(dir);
|
|
@@ -198,10 +203,16 @@ export function daemonCmdRoot(dir) {
|
|
|
198
203
|
catch {
|
|
199
204
|
/* not there: the spelling given */
|
|
200
205
|
}
|
|
206
|
+
const setUp = (d) => (process.env.VEXP_ALLOW_HOME_WORKSPACE === "1" ||
|
|
207
|
+
(upgradeAttemptKey(d) !== upgradeAttemptKey(vexpHome()) && path.dirname(d) !== d)) &&
|
|
208
|
+
["manifest.json", "vexp.toml", "workspace.json", "index.db"].some((f) => fs.existsSync(path.join(d, ".vexp", f)));
|
|
209
|
+
const below = [];
|
|
201
210
|
let current = start;
|
|
202
211
|
for (;;) {
|
|
203
|
-
if (fs.existsSync(path.join(current, ".git")))
|
|
204
|
-
return current;
|
|
212
|
+
if (fs.existsSync(path.join(current, ".git"))) {
|
|
213
|
+
return setUp(current) ? current : (below.find(setUp) ?? current);
|
|
214
|
+
}
|
|
215
|
+
below.push(current);
|
|
205
216
|
const parent = path.dirname(current);
|
|
206
217
|
if (parent === current)
|
|
207
218
|
return start;
|
|
@@ -215,8 +226,10 @@ export function daemonCmdRoot(dir) {
|
|
|
215
226
|
*
|
|
216
227
|
* - the stop half reads `<dir>/.vexp/daemon.pid`; with none there it stops
|
|
217
228
|
* nothing, and the start half then races the daemon that is still up;
|
|
218
|
-
* - `daemon-cmd` works on the git root at or above its cwd
|
|
219
|
-
*
|
|
229
|
+
* - `daemon-cmd` works on the git root at or above its cwd unless a folder
|
|
230
|
+
* below an unset-up root was set up on its own (daemonCmdRoot), so for
|
|
231
|
+
* any other workspace below one it would start a daemon on the folder
|
|
232
|
+
* above;
|
|
220
233
|
* - the engine refuses to start on the home directory, so a daemon an older
|
|
221
234
|
* build left there would be stopped and not come back.
|
|
222
235
|
*/
|