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/dist/doctor.js CHANGED
@@ -605,6 +605,42 @@ function withoutVexpCommands(line) {
605
605
  .filter((sentence) => !/[`']vexp /.test(sentence))
606
606
  .join(" ");
607
607
  }
608
+ /** ~/.vexp/config.toml, under VEXP_HOME when that is set: where the engine reads it. */
609
+ function configTomlPath() {
610
+ return path.join(vexpHome(), ".vexp", "config.toml");
611
+ }
612
+ /** Minutes as the setting gives them: whole, else seconds. */
613
+ function spokenMinutes(minutes) {
614
+ return Number.isInteger(minutes) ? `${minutes} min` : `${Math.round(minutes * 60)} s`;
615
+ }
616
+ /**
617
+ * The idle stop, as the daemon that answered sees it (index_status
618
+ * `idle_stop`, 3.3.6 and later). The setting was only to be found by reading
619
+ * the binary (field report, 2026-10-17): doctor names it, on, off, or passed
620
+ * over for a window's daemon. Null for a daemon from before it.
621
+ */
622
+ export function idleStopFinding(idle) {
623
+ if (!idle || typeof idle !== "object")
624
+ return null;
625
+ const st = idle;
626
+ const after = typeof st.after_minutes === "number" && st.after_minutes > 0 ? st.after_minutes : null;
627
+ if (after === null) {
628
+ return {
629
+ level: INFO,
630
+ message: `idle stop: off, so this daemon runs until it is stopped. To stop the daemons nobody uses, set [daemon] idle_timeout_minutes = 30 in ${configTomlPath()} ` +
631
+ `(or VEXP_IDLE_TIMEOUT_MINUTES for one daemon); the next tool call or prompt starts one again. A daemon an editor window started stops with that window instead.`,
632
+ };
633
+ }
634
+ if (st.editor_owned === true) {
635
+ return {
636
+ level: OK,
637
+ message: `idle stop: after ${spokenMinutes(after)} without use, but not this daemon: an editor window started it, and it stops when that window closes`,
638
+ };
639
+ }
640
+ const idleS = typeof st.idle_s === "number" ? st.idle_s : null;
641
+ const unused = idleS === null ? "" : idleS >= 60 ? ` (unused for ${Math.floor(idleS / 60)} min now)` : " (used in the last minute)";
642
+ return { level: OK, message: `idle stop: after ${spokenMinutes(after)} without use${unused}; the next tool call or prompt starts it again` };
643
+ }
608
644
  /**
609
645
  * What doctor says when no daemon answers for this workspace.
610
646
  *
@@ -654,6 +690,18 @@ export function daemonDownFindings(o) {
654
690
  },
655
691
  ];
656
692
  }
693
+ // Stopped on purpose: idle_timeout_minutes. Nothing to restart by hand,
694
+ // and the marker goes as soon as a daemon starts here again.
695
+ const idle = (o.idleExit ?? "").split(/\r?\n/)[0].trim();
696
+ if (idle) {
697
+ return [
698
+ {
699
+ level: INFO,
700
+ message: `no daemon is running for this project (${o.sock}): it ${idle}.\n` +
701
+ ` nothing to do: the next tool call or prompt starts it again. The setting is [daemon] idle_timeout_minutes in ${configTomlPath()}.`,
702
+ },
703
+ ];
704
+ }
657
705
  if (ext && !o.hasIndex) {
658
706
  return [
659
707
  {
@@ -1009,7 +1057,11 @@ export function partialParseVerdict(cov, coveragePath = ".vexp/coverage.json") {
1009
1057
  const files = list;
1010
1058
  const examples = files
1011
1059
  .slice(0, 5)
1012
- .map((f) => `${f.path} (${Number(f.lines) || 0} line(s) from line ${Number(f.first_line) || 0})`);
1060
+ .map((f) => {
1061
+ const broke = Number(f.broke_line) || 0;
1062
+ const at = broke > 0 ? `, the reading broke at line ${broke}` : "";
1063
+ return `${f.path} (${Number(f.lines) || 0} line(s) from line ${Number(f.first_line) || 0}${at})`;
1064
+ });
1013
1065
  const more = files.length - examples.length;
1014
1066
  return {
1015
1067
  level: WARN,
@@ -1256,7 +1308,8 @@ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv =
1256
1308
  if (!gitBash)
1257
1309
  return null;
1258
1310
  const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
1259
- // 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.
1260
1313
  if (path.win32.isAbsolute(gitBash)) {
1261
1314
  const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
1262
1315
  const dir = path.win32.dirname(gitBash);
@@ -1278,10 +1331,108 @@ export function claudeShellMissing(what, lookup) {
1278
1331
  }
1279
1332
  return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
1280
1333
  `(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
1281
- `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` +
1282
1335
  ` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
1283
1336
  `(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
1284
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
+ }
1285
1436
  /**
1286
1437
  * What the ledger adds to a failed hook probe. Its prompts arrive only
1287
1438
  * through an agent's hook — doctor's own probe is tagged manual and never
@@ -1291,14 +1442,36 @@ export function claudeShellMissing(what, lookup) {
1291
1442
  * probes' own failures: the prompts it counts come from UserPromptSubmit
1292
1443
  * hooks, and next to a failure that is real (no Git Bash for Claude Code, an
1293
1444
  * exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
1294
- * 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).
1295
1449
  */
1296
- export function hookLedgerNote(hookPrompts7d) {
1297
- if (!(hookPrompts7d > 0))
1450
+ export function hookLedgerNote(hookPrompts7d, event) {
1451
+ if (event !== "UserPromptSubmit" || !(hookPrompts7d > 0))
1298
1452
  return "";
1299
1453
  return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
1300
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.`);
1301
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
+ }
1302
1475
  /** The answers file when it exists but does not read as answers. */
1303
1476
  function unreadableAnswers(file) {
1304
1477
  // The same loader the CLI and the extension read the answers with: it
@@ -3257,6 +3430,11 @@ async function doctorChecks(onWorkspace) {
3257
3430
  startBlocked = fs.readFileSync(path.join(ws.root, ".vexp", "start-blocked"), "utf-8");
3258
3431
  }
3259
3432
  catch { /* the last start was not refused */ }
3433
+ let idleExit;
3434
+ try {
3435
+ idleExit = fs.readFileSync(path.join(ws.root, ".vexp", "idle-exit"), "utf-8");
3436
+ }
3437
+ catch { /* the last daemon did not stop for idleness */ }
3260
3438
  const what = notAProject(ws.root);
3261
3439
  const down = daemonDownFindings({
3262
3440
  sock,
@@ -3264,6 +3442,7 @@ async function doctorChecks(onWorkspace) {
3264
3442
  hasIndex: fs.existsSync(path.join(ws.root, ".vexp", "index.db")),
3265
3443
  startBlocked,
3266
3444
  outside: what ? { root: ws.root, what } : undefined,
3445
+ idleExit,
3267
3446
  });
3268
3447
  closedInIde = down.every((f) => f.level === INFO);
3269
3448
  for (const f of down)
@@ -3303,6 +3482,9 @@ async function doctorChecks(onWorkspace) {
3303
3482
  ? "loading (the model is still starting — this is not the rule compressor settling in)"
3304
3483
  : st.compressor ?? "unknown (older daemon)";
3305
3484
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
3485
+ const idleStop = idleStopFinding(st.idle_stop);
3486
+ if (idleStop)
3487
+ emit(idleStop);
3306
3488
  if (st.llm_configured_but_inactive === true) {
3307
3489
  // The daemon knows WHY, and doctor was throwing it away: it printed
3308
3490
  // the same "restart to load the model" the sidebar prints, to a user
@@ -3759,9 +3941,11 @@ async function doctorChecks(onWorkspace) {
3759
3941
  const sPath = path.join(ws.root, ".claude", "settings.json");
3760
3942
  let guardHooks = [];
3761
3943
  let settingsReadable = false;
3944
+ let optIns = { guard: true, interventions: false };
3762
3945
  try {
3763
3946
  const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
3764
3947
  settingsReadable = true;
3948
+ optIns = { ...claudeOptInsOn(settings), guard: true };
3765
3949
  const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
3766
3950
  for (const m of pre) {
3767
3951
  const hks = Array.isArray(m?.hooks) ? m.hooks : [];
@@ -3791,7 +3975,7 @@ async function doctorChecks(onWorkspace) {
3791
3975
  // all — the entry LOOKS installed and enforces nothing. This was
3792
3976
  // invisible for days because doctor used to skip the live check
3793
3977
  // on Windows entirely.
3794
- 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).`);
3795
3979
  }
3796
3980
  if (timeoutS > 600) {
3797
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.`);
@@ -3800,6 +3984,15 @@ async function doctorChecks(onWorkspace) {
3800
3984
  line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
3801
3985
  continue;
3802
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
+ }
3803
3996
  // Run it exactly as Claude Code would: exec form = direct spawn with
3804
3997
  // the placeholder substituted by the host; shell form = the shell
3805
3998
  // Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
@@ -3887,6 +4080,7 @@ async function doctorChecks(onWorkspace) {
3887
4080
  // --interventions` installs it for anyone who wants that trade.
3888
4081
  const wanted = [
3889
4082
  { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
4083
+ { event: "PreToolUse", marker: "vexp-subagent", label: "subagent orientation" },
3890
4084
  { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
3891
4085
  { event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
3892
4086
  ];
@@ -3935,15 +4129,31 @@ async function doctorChecks(onWorkspace) {
3935
4129
  }
3936
4130
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
3937
4131
  if (!fs.existsSync(scriptPath)) {
3938
- 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);
3939
4142
  continue;
3940
4143
  }
3941
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" };
3942
4150
  const payload = JSON.stringify(w.event === "SessionStart"
3943
4151
  ? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
3944
4152
  : w.event === "Stop"
3945
4153
  ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
3946
- : { 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 });
3947
4157
  // In Claude Code's own shell (claudeHookSpawnSpec), from the project.
3948
4158
  const bash = process.platform === "win32" ? claudeGitBash() : null;
3949
4159
  const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
@@ -3959,15 +4169,27 @@ async function doctorChecks(onWorkspace) {
3959
4169
  encoding: "utf-8",
3960
4170
  windowsHide: true,
3961
4171
  });
4172
+ const lost = w.event === "PreToolUse" && !r.error && r.status === 0 ? droppedAgentKeys(r.stdout, agentInput) : [];
3962
4173
  if (r.error) {
3963
- 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));
3964
4175
  }
3965
4176
  else if (r.status !== 0) {
3966
- 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.`);
3967
4187
  }
3968
4188
  else {
3969
4189
  // Silence is a legitimate outcome for the hint hook (the classifier
3970
- // 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.
3971
4193
  const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
3972
4194
  line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
3973
4195
  }
@@ -4053,7 +4275,7 @@ async function doctorChecks(onWorkspace) {
4053
4275
  : [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
4054
4276
  const baked = (() => {
4055
4277
  try {
4056
- // One pattern covers both twins: VEXP_BIN="path" (bash) and
4278
+ // One pattern covers both twins: VEXP_BIN="path" (sh) and
4057
4279
  // set "VEXP_BIN=path" (cmd).
4058
4280
  const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
4059
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