vexp-cli 3.3.7 → 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/dist/doctor.js CHANGED
@@ -1308,7 +1308,8 @@ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv =
1308
1308
  if (!gitBash)
1309
1309
  return null;
1310
1310
  const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
1311
- // The `bash` inside the command must resolve to Git Bash too, not WSL's.
1311
+ // The `sh` inside the command (`bash` in an older entry) must resolve
1312
+ // beside Git Bash too, as in Claude Code, never to WSL's launcher.
1312
1313
  if (path.win32.isAbsolute(gitBash)) {
1313
1314
  const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
1314
1315
  const dir = path.win32.dirname(gitBash);
@@ -1330,10 +1331,108 @@ export function claudeShellMissing(what, lookup) {
1330
1331
  }
1331
1332
  return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
1332
1333
  `(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
1333
- `It then runs them with PowerShell, which cannot run vexp's bash hooks. A 'bash' on PATH does not count: with WSL installed it is WSL's, which cannot see this project.\n` +
1334
+ `It then runs them with PowerShell, which cannot run vexp's sh hooks. A 'bash' on PATH does not count: with WSL installed it is WSL's, which cannot see this project.\n` +
1334
1335
  ` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
1335
1336
  `(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
1336
1337
  }
1338
+ /**
1339
+ * Does this Claude Code entry start vexp's script with `bash`? Setup wrote
1340
+ * that form until the hooks became POSIX sh; both installers now replace it,
1341
+ * matched by script name, with `sh "..."`. Off Windows Claude Code runs
1342
+ * either as `/bin/sh -c '<command>'`, so the old one keeps working wherever
1343
+ * a bash is on PATH, and doctor says nothing of it there.
1344
+ */
1345
+ export function isBashHookEntry(command) {
1346
+ return /^\s*bash\s/.test(command);
1347
+ }
1348
+ /** Is `name` an executable file in a folder of PATH? POSIX lookup. */
1349
+ export function onPosixPath(name, env = process.env, canExec = (p) => {
1350
+ try {
1351
+ fs.accessSync(p, fs.constants.X_OK);
1352
+ return fs.statSync(p).isFile();
1353
+ }
1354
+ catch {
1355
+ return false;
1356
+ }
1357
+ }) {
1358
+ return (env.PATH ?? "").split(path.posix.delimiter).some((dir) => dir !== "" && canExec(path.posix.join(dir, name)));
1359
+ }
1360
+ /**
1361
+ * What a Claude Code hook that fails at its event costs. "Fails on every
1362
+ * prompt" was the line for the subagent hook too, which never sees a prompt:
1363
+ * on Agent the subagent still starts, only blind (review, 2026-10-24).
1364
+ */
1365
+ export function claudeHookFailureCost(event) {
1366
+ if (event === "PreToolUse")
1367
+ return "Claude Code reports a hook error on every Agent call and each subagent starts without orientation";
1368
+ if (event === "SessionStart")
1369
+ return "the hook fails at every session start";
1370
+ if (event === "Stop")
1371
+ return "the hook fails at the end of every reply";
1372
+ return "the hook fails on every prompt";
1373
+ }
1374
+ /**
1375
+ * Which opt-ins this settings.json shows on. Setup keeps no record of its
1376
+ * flags, so the entries are the only answer, and a rewrite has to carry
1377
+ * them: `vexp setup` without --guard-strict or --interventions REMOVES those
1378
+ * hooks, script and entry (review, 2026-10-24).
1379
+ */
1380
+ export function claudeOptInsOn(settings) {
1381
+ const hooks = settings?.hooks ?? {};
1382
+ const commands = Object.values(hooks)
1383
+ .flatMap((groups) => (Array.isArray(groups) ? groups : []))
1384
+ .flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
1385
+ .map((h) => (typeof h?.command === "string" ? h.command : ""));
1386
+ return {
1387
+ guard: commands.some((c) => c.includes("vexp-guard")),
1388
+ interventions: commands.some((c) => /vexp-(verify|edit-hint|read-hint|bash-cap)\b/.test(c)),
1389
+ };
1390
+ }
1391
+ /**
1392
+ * How vexp's Claude Code entries get rewritten, with the install at hand:
1393
+ * the setup that writes them, and for someone with the extension a restart
1394
+ * of the window, since the extension rewrites them at every start in a
1395
+ * project where it sets Claude Code up (and the guard when vexp.guardStrict
1396
+ * is on).
1397
+ *
1398
+ * The project's opt-ins ride along (claudeOptInsOn). The plain command,
1399
+ * offered for an old `bash` stop gate, rewrote nothing: a setup without
1400
+ * --interventions takes the gate out, so a user who had chosen it and
1401
+ * followed doctor lost it without a word (review, 2026-10-24). The extension
1402
+ * writes none of the --interventions hooks and takes them out at every
1403
+ * start, which a reopen does too; the line says so rather than call that a
1404
+ * rewrite.
1405
+ */
1406
+ export function claudeHooksRewriteRemedy(install, root, on = { guard: false, interventions: false }) {
1407
+ const ide = ideName(install.extensions);
1408
+ const reopen = on.guard
1409
+ ? `reopen this project in ${ide} with the vexp.guardStrict setting on`
1410
+ : `reopen this project in ${ide} (the vexp extension rewrites them at every start where it sets Claude Code up)`;
1411
+ const takesOut = on.interventions
1412
+ ? "; the extension takes out the stop gate and the other opt-in hooks of 'vexp setup --interventions' as it does so, since only the vexp CLI writes them"
1413
+ : "";
1414
+ if (extensionOnly(install)) {
1415
+ return (on.guard ? reopen : `${reopen}, or run "vexp: Setup Agents" from the Command Palette and tick Claude Code`) + takesOut;
1416
+ }
1417
+ const flags = `${on.guard ? " --guard-strict" : ""}${on.interventions ? " --interventions" : ""}`;
1418
+ const setup = `run '${setupAgentsCommand(["Claude Code"], root)}${flags}'`;
1419
+ return install.extensions.length > 0 ? `${setup}, or ${reopen}${takesOut}` : setup;
1420
+ }
1421
+ /**
1422
+ * The line for a `bash` entry on a machine with no bash. Alpine and the
1423
+ * other BusyBox systems ship sh alone, and the alpine-x64/arm64 builds put
1424
+ * vexp there: `bash "..."` exits 127 at every event, a hook error each time
1425
+ * and no orientation. Off Windows only: there Claude Code runs hooks with
1426
+ * Git Bash, which brings its own bash (claudeShellMissing covers its
1427
+ * absence). Null when the entry is not the old form or a bash is on PATH;
1428
+ * the probe that follows judges it then.
1429
+ */
1430
+ export function claudeBashEntryLine(what, command, cost, remedy, platform = process.platform, bashOnPath = () => onPosixPath("bash")) {
1431
+ if (platform === "win32" || !isBashHookEntry(command) || bashOnPath())
1432
+ return null;
1433
+ return (`${what} starts vexp's script with bash, and this machine has no bash on PATH (Alpine and other BusyBox systems ship only sh): ${cost}.\n` +
1434
+ ` vexp writes this entry with sh now; to fix it: ${remedy}.`);
1435
+ }
1337
1436
  /**
1338
1437
  * What the ledger adds to a failed hook probe. Its prompts arrive only
1339
1438
  * through an agent's hook — doctor's own probe is tagged manual and never
@@ -1343,14 +1442,36 @@ export function claudeShellMissing(what, lookup) {
1343
1442
  * probes' own failures: the prompts it counts come from UserPromptSubmit
1344
1443
  * hooks, and next to a failure that is real (no Git Bash for Claude Code, an
1345
1444
  * exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
1346
- * reader out of the one line he had to act on (review, 2026-09-24).
1445
+ * reader out of the one line he had to act on (review, 2026-09-24). And only
1446
+ * on the UserPromptSubmit probe's: a count of prompts says nothing of
1447
+ * vexp-subagent.sh, and an exit 2 line read "Claude Code REFUSES the Agent
1448
+ * call" and "may be the probe's alone" at once (review, 2026-10-24).
1347
1449
  */
1348
- export function hookLedgerNote(hookPrompts7d) {
1349
- if (!(hookPrompts7d > 0))
1450
+ export function hookLedgerNote(hookPrompts7d, event) {
1451
+ if (event !== "UserPromptSubmit" || !(hookPrompts7d > 0))
1350
1452
  return "";
1351
1453
  return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
1352
1454
  `(doctor's own probe is never counted), so an agent's hooks do reach vexp in this project — this failure may be the probe's alone.`);
1353
1455
  }
1456
+ /**
1457
+ * The keys of the Agent call the subagent hook's answer leaves out. Claude
1458
+ * Code replaces tool_input with updatedInput instead of merging the two, so
1459
+ * a key missing there is missing from the call itself: the agent type, the
1460
+ * background flag. No output, or output without updatedInput, leaves the
1461
+ * call as the model wrote it and drops nothing.
1462
+ */
1463
+ export function droppedAgentKeys(stdout, toolInput) {
1464
+ let updated;
1465
+ try {
1466
+ updated = JSON.parse(String(stdout ?? "").trim() || "null")?.hookSpecificOutput?.updatedInput;
1467
+ }
1468
+ catch {
1469
+ return [];
1470
+ }
1471
+ if (!updated || typeof updated !== "object")
1472
+ return [];
1473
+ return Object.keys(toolInput).filter((k) => !(k in updated));
1474
+ }
1354
1475
  /** The answers file when it exists but does not read as answers. */
1355
1476
  function unreadableAnswers(file) {
1356
1477
  // The same loader the CLI and the extension read the answers with: it
@@ -3820,9 +3941,11 @@ async function doctorChecks(onWorkspace) {
3820
3941
  const sPath = path.join(ws.root, ".claude", "settings.json");
3821
3942
  let guardHooks = [];
3822
3943
  let settingsReadable = false;
3944
+ let optIns = { guard: true, interventions: false };
3823
3945
  try {
3824
3946
  const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
3825
3947
  settingsReadable = true;
3948
+ optIns = { ...claudeOptInsOn(settings), guard: true };
3826
3949
  const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
3827
3950
  for (const m of pre) {
3828
3951
  const hks = Array.isArray(m?.hooks) ? m.hooks : [];
@@ -3852,7 +3975,7 @@ async function doctorChecks(onWorkspace) {
3852
3975
  // all — the entry LOOKS installed and enforces nothing. This was
3853
3976
  // invisible for days because doctor used to skip the live check
3854
3977
  // on Windows entirely.
3855
- line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (bash-prefixed shell form).`);
3978
+ line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (sh-prefixed shell form).`);
3856
3979
  }
3857
3980
  if (timeoutS > 600) {
3858
3981
  line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
@@ -3861,6 +3984,15 @@ async function doctorChecks(onWorkspace) {
3861
3984
  line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
3862
3985
  continue;
3863
3986
  }
3987
+ // An entry from before the hooks became POSIX sh, on a machine with
3988
+ // no bash (Alpine): the probe would only print sh's exit 127.
3989
+ const noBash = execForm
3990
+ ? null
3991
+ : claudeBashEntryLine("the guard hook", cmd, "Claude Code reports a hook error on every search and the guard enforces nothing", claudeHooksRewriteRemedy(install, ws.root, optIns));
3992
+ if (noBash) {
3993
+ line(BAD, noBash);
3994
+ continue;
3995
+ }
3864
3996
  // Run it exactly as Claude Code would: exec form = direct spawn with
3865
3997
  // the placeholder substituted by the host; shell form = the shell
3866
3998
  // Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
@@ -3948,6 +4080,7 @@ async function doctorChecks(onWorkspace) {
3948
4080
  // --interventions` installs it for anyone who wants that trade.
3949
4081
  const wanted = [
3950
4082
  { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
4083
+ { event: "PreToolUse", marker: "vexp-subagent", label: "subagent orientation" },
3951
4084
  { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
3952
4085
  { event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
3953
4086
  ];
@@ -3996,15 +4129,31 @@ async function doctorChecks(onWorkspace) {
3996
4129
  }
3997
4130
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
3998
4131
  if (!fs.existsSync(scriptPath)) {
3999
- line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
4132
+ // sh exits 127, an error Claude Code reports and steps over each
4133
+ // time the event fires (claudeHookFailureCost says what that costs).
4134
+ line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — ${claudeHookFailureCost(w.event)}.`);
4135
+ continue;
4136
+ }
4137
+ // A `bash` entry from before the hooks became POSIX sh, where there
4138
+ // is no bash: the same 127 at every event, and a rewrite fixes it.
4139
+ const noBash = claudeBashEntryLine(`${w.event} hook`, hook.command, claudeHookFailureCost(w.event), claudeHooksRewriteRemedy(install, ws.root, claudeOptInsOn(settings)));
4140
+ if (noBash) {
4141
+ line(BAD, noBash);
4000
4142
  continue;
4001
4143
  }
4002
4144
  // Feed it a payload shaped like the real event and see it survive.
4145
+ // Like every probe here it carries no hook_event_name and no
4146
+ // transcript_path: those are what tell the engine a call came from an
4147
+ // agent (payload_is_from_hook, main.rs), and a probe with them would
4148
+ // be counted in the activity ledger as an agent's.
4149
+ const agentInput = { description: "vexp doctor probe", prompt: "vexp doctor probe", subagent_type: "general-purpose" };
4003
4150
  const payload = JSON.stringify(w.event === "SessionStart"
4004
4151
  ? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
4005
4152
  : w.event === "Stop"
4006
4153
  ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
4007
- : { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
4154
+ : w.event === "PreToolUse"
4155
+ ? { session_id: "vexp-doctor", tool_name: "Agent", tool_input: agentInput, cwd: ws.root }
4156
+ : { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
4008
4157
  // In Claude Code's own shell (claudeHookSpawnSpec), from the project.
4009
4158
  const bash = process.platform === "win32" ? claudeGitBash() : null;
4010
4159
  const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
@@ -4020,15 +4169,27 @@ async function doctorChecks(onWorkspace) {
4020
4169
  encoding: "utf-8",
4021
4170
  windowsHide: true,
4022
4171
  });
4172
+ const lost = w.event === "PreToolUse" && !r.error && r.status === 0 ? droppedAgentKeys(r.stdout, agentInput) : [];
4023
4173
  if (r.error) {
4024
- line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d));
4174
+ line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d, w.event));
4025
4175
  }
4026
4176
  else if (r.status !== 0) {
4027
- line(BAD, `${w.event} hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Claude Code treats this as a failure and continues without vexp.` + hookLedgerNote(hookPrompts7d));
4177
+ // Exit 2 from a PreToolUse hook is not a failure Claude Code steps
4178
+ // over: it refuses the tool call. The script setup writes ends on
4179
+ // `exit 0` for exactly this reason, so one that exits 2 is not it.
4180
+ const consequence = w.event === "PreToolUse" && r.status === 2
4181
+ ? "on exit 2 Claude Code REFUSES the Agent call, so no subagent can start"
4182
+ : "Claude Code treats this as a failure and continues without vexp";
4183
+ line(BAD, `${w.event} hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — ${consequence}.` + hookLedgerNote(hookPrompts7d, w.event));
4184
+ }
4185
+ else if (lost.length > 0) {
4186
+ line(BAD, `${w.event} hook rewrites the Agent call without ${lost.join(", ")} — Claude Code replaces the call's input with the hook's, so every subagent loses them; update the vexp engine.`);
4028
4187
  }
4029
4188
  else {
4030
4189
  // Silence is a legitimate outcome for the hint hook (the classifier
4031
- // decides), so report the daemon's reason instead of guessing.
4190
+ // decides), so report the daemon's reason instead of guessing. The
4191
+ // same for the subagent hook: no seed is no output, and the call
4192
+ // goes ahead as the model wrote it.
4032
4193
  const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
4033
4194
  line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
4034
4195
  }
@@ -4114,7 +4275,7 @@ async function doctorChecks(onWorkspace) {
4114
4275
  : [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
4115
4276
  const baked = (() => {
4116
4277
  try {
4117
- // One pattern covers both twins: VEXP_BIN="path" (bash) and
4278
+ // One pattern covers both twins: VEXP_BIN="path" (sh) and
4118
4279
  // set "VEXP_BIN=path" (cmd).
4119
4280
  const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
4120
4281
  return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
@@ -11,7 +11,7 @@
11
11
  * platform uses the right "daemon is listening" signal. The healthy marker
12
12
  * (removed by the daemon on graceful shutdown) gates both branches.
13
13
  */
14
- export const VEXP_GUARD_HOOK = `#!/bin/bash
14
+ export const VEXP_GUARD_HOOK = `#!/bin/sh
15
15
  # vexp-guard: block Grep/Glob/Read when the vexp daemon is running AND healthy.
16
16
  # Cross-platform: Unix/macOS use the domain socket file (.vexp/daemon.sock);
17
17
  # Windows (Git Bash) has no socket file, so the daemon writes .vexp/daemon.pipe.
@@ -31,7 +31,7 @@ vexp_allow() {
31
31
  }
32
32
 
33
33
  # Healthy marker is the portable "daemon is up" signal; the daemon removes it on
34
- # graceful shutdown. Required on every platform — if it's missing, fail open.
34
+ # graceful shutdown. Required on every platform - if it's missing, fail open.
35
35
  [ -f "$HEALTHY" ] || vexp_allow
36
36
 
37
37
  case "$(uname -s 2>/dev/null)" in
@@ -207,8 +207,8 @@ export const VexpGuard = async ({ directory, worktree }) => {
207
207
  * never touch the shell, never block a target vexp did not index — expressed in
208
208
  * the third dialect we now have to speak.
209
209
  *
210
- * Written in NODE, not bash, and that is deliberate. The Claude Code guard is a
211
- * bash script and it silently enforced NOTHING on Windows for several releases
210
+ * Written in NODE, not shell, and that is deliberate. The Claude Code guard is a
211
+ * shell script and it silently enforced NOTHING on Windows for several releases
212
212
  * (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
213
213
  * expensive bug this file has produced. Cursor documents no PowerShell variant
214
214
  * of `command` and does not say which shell runs it on Windows, so a bash guard
@@ -381,6 +381,15 @@ process.stdin.on("end", () => {
381
381
  * hint suggesting run_pipeline. All logic (stdin parsing, classification,
382
382
  * hook-JSON output) lives in the Rust binary so the script is OS-neutral;
383
383
  * Claude Code runs shell-form hooks through Git Bash on Windows.
384
+ *
385
+ * POSIX sh, like every Claude Code hook script in this file, and registered
386
+ * as `sh "$CLAUDE_PROJECT_DIR/..."`. They were bash scripts run by `bash`,
387
+ * and Alpine (BusyBox) has no bash: on an Alpine remote, the platform of the
388
+ * alpine-x64/arm64 builds, every prompt, session start and Agent call
389
+ * reported a hook error and got no orientation. Nothing here needed bash.
390
+ * Keep it that way: test/hook-posix.test.ts rejects the usual bashisms and
391
+ * runs every baked script under dash.
392
+ *
384
393
  * FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
385
394
  * timeout) exits 0 with no output = vanilla behavior, never a broken
386
395
  * prompt. Keep in lockstep with the VS Code extension copy.
@@ -392,13 +401,45 @@ process.stdin.on("end", () => {
392
401
  * line: Codex hashes that line for its trust record, and a change there would
393
402
  * make every user approve the hook again. Binaries that predate it ignore it.
394
403
  */
395
- export const VEXP_HINT_HOOK = `#!/bin/bash
404
+ export const VEXP_HINT_HOOK = `#!/bin/sh
396
405
  # vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
397
406
  VEXP_BIN="__VEXP_BIN__"
398
407
  [ -x "$VEXP_BIN" ] || exit 0
399
408
  VEXP_HOOK_AGENT="__VEXP_AGENT__" "$VEXP_BIN" prompt-hint 2>/dev/null
400
409
  exit 0
401
410
  `;
411
+ /**
412
+ * Orientation for a delegated task: PreToolUse on Claude Code's Agent tool.
413
+ *
414
+ * A subagent started blind (field report, 2026-10-24). UserPromptSubmit fires
415
+ * for what the user types and never for the prompt the model writes for a
416
+ * subagent; the built-in Explore and Plan agents run without CLAUDE.md; and
417
+ * vexp's tools reach a subagent only as bare deferred names. SubagentStart is
418
+ * no way in either: it is handed the agent's id and type, not its task.
419
+ *
420
+ * PreToolUse on Agent is handed the task, as tool_input.prompt, and Claude
421
+ * Code starts the subagent on `updatedInput` when the hook returns one. It
422
+ * REPLACES the input, it does not merge it, so `subagent-hint` echoes every
423
+ * key with the seed appended to the prompt. It sets no permissionDecision:
424
+ * without one the rewrite is applied silently in default, acceptEdits, auto,
425
+ * plan and bypassPermissions mode (verified on Claude Code 2.1.217 and
426
+ * 2.1.309), and an "allow" would skip the auto-mode classifier. The parent's
427
+ * transcript keeps the prompt the model wrote; only the subagent's first
428
+ * message carries the seed, so only the subagent pays for it.
429
+ *
430
+ * The last `exit 0` matters more here than in any other hook: a PreToolUse
431
+ * hook that exits 2 BLOCKS the tool call, and an engine older than
432
+ * `subagent-hint` exits 2 from its argument parser. Without it, every
433
+ * delegation would be refused until the engine was updated. Claude Code is
434
+ * the only agent that runs this script, hence the agent name written in.
435
+ */
436
+ export const VEXP_SUBAGENT_HOOK = `#!/bin/sh
437
+ # vexp-subagent: orientation for a delegated task (PreToolUse on Agent). Fails open.
438
+ VEXP_BIN="__VEXP_BIN__"
439
+ [ -x "$VEXP_BIN" ] || exit 0
440
+ VEXP_HOOK_AGENT="claude-code" "$VEXP_BIN" subagent-hint 2>/dev/null
441
+ exit 0
442
+ `;
402
443
  /**
403
444
  * v5: the coupling, delivered on the edit that needs it (PostToolUse).
404
445
  *
@@ -417,14 +458,14 @@ exit 0
417
458
  * FAIL-OPEN like every other hook: no binary, no daemon, nothing to say —
418
459
  * exit 0 silent.
419
460
  */
420
- export const VEXP_EDIT_HINT_HOOK = `#!/bin/bash
461
+ export const VEXP_EDIT_HINT_HOOK = `#!/bin/sh
421
462
  # vexp-edit-hint: what else references the file you just changed. Fails open.
422
463
  VEXP_BIN="__VEXP_BIN__"
423
464
  [ -x "$VEXP_BIN" ] || exit 0
424
465
  "$VEXP_BIN" edit-hint 2>/dev/null
425
466
  exit 0
426
467
  `;
427
- export const VEXP_READ_HINT_HOOK = `#!/bin/bash
468
+ export const VEXP_READ_HINT_HOOK = `#!/bin/sh
428
469
  # vexp-read-hint: answer a whole-file read of a large file with its skeleton.
429
470
  # The only mechanism vexp has that SUBTRACTS tokens. Fails open.
430
471
  VEXP_BIN="__VEXP_BIN__"
@@ -432,7 +473,7 @@ VEXP_BIN="__VEXP_BIN__"
432
473
  "$VEXP_BIN" read-hint 2>/dev/null
433
474
  exit 0
434
475
  `;
435
- export const VEXP_BASH_CAP_HOOK = `#!/bin/bash
476
+ export const VEXP_BASH_CAP_HOOK = `#!/bin/sh
436
477
  # vexp-bash-cap: bound the output of a shell command that has no bound of its
437
478
  # own. The other door: narrowing reads alone just moved the work here. Fails
438
479
  # open, and never touches a build or a test.
@@ -548,6 +589,10 @@ export function bakeEditHintHook(binaryPath) {
548
589
  export function vexpHintHookScript(binaryPath, agent = "claude-code") {
549
590
  return VEXP_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/")).replace("__VEXP_AGENT__", agent);
550
591
  }
592
+ /** Bake the binary path into the subagent hook script. */
593
+ export function vexpSubagentHookScript(binaryPath) {
594
+ return VEXP_SUBAGENT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
595
+ }
551
596
  /**
552
597
  * Windows twin of the hint hook, for Codex. A Windows user with neither Git
553
598
  * Bash nor WSL has no `bash`, so `bash "..."` failed on every prompt and
@@ -611,7 +656,7 @@ export function codexWindowsHookCommand(cmdPath) {
611
656
  * the script is a cross-OS wrapper that exits silently when anything is
612
657
  * missing.
613
658
  */
614
- export const VEXP_SEARCH_HOOK = `#!/bin/bash
659
+ export const VEXP_SEARCH_HOOK = `#!/bin/sh
615
660
  # vexp-search: answer a grep with structure instead of lines. Fails open.
616
661
  VEXP_BIN="__VEXP_BIN__"
617
662
  [ -x "$VEXP_BIN" ] || exit 0
@@ -629,7 +674,7 @@ export function vexpSearchHookScript(binaryPath) {
629
674
  * imports. FAIL-OPEN by construction: no binary / no daemon / timeout =
630
675
  * silent exit 0 = normal stop.
631
676
  */
632
- export const VEXP_STOP_GATE_HOOK = `#!/bin/bash
677
+ export const VEXP_STOP_GATE_HOOK = `#!/bin/sh
633
678
  # vexp-verify: mechanical completion gate on Stop (Horizon). Fails open.
634
679
  VEXP_BIN="__VEXP_BIN__"
635
680
  [ -x "$VEXP_BIN" ] || exit 0
@@ -647,7 +692,7 @@ export function vexpStopGateHookScript(binaryPath) {
647
692
  * orientation) and re-injects one compact context paragraph. FAIL-OPEN:
648
693
  * no binary / no daemon / fresh start = silent exit 0.
649
694
  */
650
- export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/bash
695
+ export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/sh
651
696
  # vexp-restore: context lifecycle restore on SessionStart (compact/resume). Fails open.
652
697
  VEXP_BIN="__VEXP_BIN__"
653
698
  [ -x "$VEXP_BIN" ] || exit 0