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 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
 
@@ -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/daemon: `vexp-core mcp` auto-proxies to a running daemon when
2509
- * reachable and otherwise runs an embedded in-process index. Override with the
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
- const filtered = existingPreToolUse.filter((h) => !isVexpGuardHookEntry(h));
3049
- if (filtered.length !== existingPreToolUse.length) {
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 matchers, malformed)
3091
- const filtered = existingPreToolUse.filter((h) => !isVexpGuardHookEntry(h));
3092
- // Shell form with an explicit `bash` prefix and a QUOTED path — the one
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
- // `bash "$VAR"` works there and on unix alike.
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
- filtered.push({
3130
+ const placed = placeVexpHook(existingPreToolUse, isVexpGuardHookEntry, {
3104
3131
  matcher: "Grep|Glob|Regex",
3105
3132
  hooks: [
3106
3133
  {
3107
3134
  type: "command",
3108
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh"',
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: filtered };
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 filtered = existing.filter((h) => !isVexpHookEntry(h, "vexp-restore"));
3176
- filtered.push({
3259
+ const placed = placeVexpHook(existing, (h) => isVexpHookEntry(h, "vexp-restore"), {
3177
3260
  hooks: [
3178
3261
  {
3179
3262
  type: "command",
3180
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-restore.sh"',
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: filtered };
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
- 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({
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-verify.sh"',
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
- 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"));
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.filter((h) => !/vexp-(read-hint|bash-cap)/.test(JSON.stringify(h)));
3346
- if (filtered.length === existing.length)
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
- filtered.push({
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: `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-${name}.sh"`,
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: filtered };
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
- filtered.push({
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
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: filtered };
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
- const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
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.push({
3573
+ const filtered = placeVexpHook(existing, isVexpHintHookEntry, {
3497
3574
  hooks: [
3498
3575
  {
3499
3576
  type: "command",
3500
- command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-hint.sh"',
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.filter((h) => !isVexpEditHintHookEntry(h));
3516
- if (couplingOn) {
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: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-edit-hint.sh"',
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, SKIP vexp. Otherwise one
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. A daemon an editor window started stops
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);
@@ -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`: the nearest one with a
191
- * `.git` at or above it, else `dir` itself (find_repo_root in vexp-core).
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, so for a
219
- * workspace below one it would start a daemon on the folder above;
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
  */