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 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
@@ -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
- const filtered = existingPreToolUse.filter((h) => !isVexpGuardHookEntry(h));
3049
- if (filtered.length !== existingPreToolUse.length) {
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 matchers, malformed)
3091
- const filtered = existingPreToolUse.filter((h) => !isVexpGuardHookEntry(h));
3092
- // Shell form with an explicit `bash` prefix and a QUOTED path — the one
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
- // `bash "$VAR"` works there and on unix alike.
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
- filtered.push({
3113
+ const placed = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, {
3104
3114
  matcher: "Grep|Glob|Regex",
3105
3115
  hooks: [
3106
3116
  {
3107
3117
  type: "command",
3108
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh"',
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: filtered };
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 filtered = existing.filter((h) => !isVexpHookEntry(h, "vexp-restore"));
3176
- filtered.push({
3242
+ const placed = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-restore"), {
3177
3243
  hooks: [
3178
3244
  {
3179
3245
  type: "command",
3180
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-restore.sh"',
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: filtered };
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
- const filtered = existing.filter((h) => !isVexpHookEntry(h, "vexp-verify"));
3233
- // Cross-OS shape as everywhere: bash + QUOTED path; timeout in SECONDS.
3234
- if (gateOn) {
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-verify.sh"',
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
- if (!h || typeof h !== "object")
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.filter((h) => !/vexp-(read-hint|bash-cap)/.test(JSON.stringify(h)));
3346
- if (filtered.length === existing.length)
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
- filtered.push({
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: `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-${name}.sh"`,
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: filtered };
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
- filtered.push({
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
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: filtered };
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
- const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
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.push({
3556
+ const filtered = placeVexpHook(existing, isVexpHintHookEntry, {
3497
3557
  hooks: [
3498
3558
  {
3499
3559
  type: "command",
3500
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-hint.sh"',
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.filter((h) => !isVexpEditHintHookEntry(h));
3516
- if (couplingOn) {
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-edit-hint.sh"',
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, SKIP vexp. Otherwise one
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];