vexp-cli 2.6.2 → 2.7.0

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.
@@ -10,7 +10,7 @@ import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import * as os from "os";
12
12
  import * as crypto from "crypto";
13
- import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
13
+ import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Constants
16
16
  // ---------------------------------------------------------------------------
@@ -403,6 +403,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
403
403
  // Event-driven hint hook: default-on (non-blocking, fail-open — the
404
404
  // 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
405
405
  const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
406
+ installClaudeCodeSearchHook(workspaceRoot, binaryPath);
406
407
  installClaudeCodeStopGate(workspaceRoot, binaryPath);
407
408
  installClaudeCodeSessionContext(workspaceRoot, binaryPath);
408
409
  if (hintResult) {
@@ -2026,6 +2027,69 @@ function isVexpHintHookEntry(h) {
2026
2027
  * opt-in applies to DENY hooks, which add a failure mode; this one cannot
2027
2028
  * block anything).
2028
2029
  */
2030
+ /** Does this PreToolUse entry belong to us? */
2031
+ function isVexpSearchHookEntry(h) {
2032
+ if (!h || typeof h !== "object")
2033
+ return false;
2034
+ const hks = h.hooks;
2035
+ return Array.isArray(hks) && hks.some((hook) => typeof hook?.command === "string" &&
2036
+ hook.command.includes("vexp-search"));
2037
+ }
2038
+ /**
2039
+ * v4 M1: install the search answer as a PreToolUse hook on Bash.
2040
+ *
2041
+ * Matcher is Bash and nothing else. The hook rewrites only a recursive grep
2042
+ * for names this index defines, keeps the original command after a `||`, and
2043
+ * prints nothing at all in every other case — so a session where vexp has no
2044
+ * opinion is byte-for-byte a session without vexp.
2045
+ *
2046
+ * Why a hook rather than a tool: 9 of 227 measured agent sessions called the
2047
+ * MCP tools. All 227 called Bash.
2048
+ */
2049
+ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
2050
+ const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2051
+ const hookPath = path.join(hookDir, "vexp-search.sh");
2052
+ const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
2053
+ const script = vexpSearchHookScript(binaryPath);
2054
+ fs.mkdirSync(hookDir, { recursive: true });
2055
+ const existed = fs.existsSync(hookPath);
2056
+ const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2057
+ if (!scriptIdentical)
2058
+ fs.writeFileSync(hookPath, script, { mode: 0o755 });
2059
+ const read = readJsonConfigSafe(settingsPath);
2060
+ if (!read.ok) {
2061
+ warnUnparseable(settingsPath);
2062
+ return scriptIdentical ? null : existed ? "updated" : "created";
2063
+ }
2064
+ const settings = read.data;
2065
+ const hooks = (settings.hooks ?? {});
2066
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
2067
+ const filtered = existing.filter((h) => !isVexpSearchHookEntry(h));
2068
+ // Same shell-form-with-quoted-path shape as every other hook we write: exec
2069
+ // form cannot run a .sh on Windows, and an unquoted path word-splits on
2070
+ // "C:\Program Files" (both learned the hard way, c4a0b9e).
2071
+ filtered.push({
2072
+ matcher: "Bash",
2073
+ hooks: [
2074
+ {
2075
+ type: "command",
2076
+ command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
2077
+ timeout: 10,
2078
+ },
2079
+ ],
2080
+ });
2081
+ const merged = { ...hooks, PreToolUse: filtered };
2082
+ const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
2083
+ if (scriptIdentical && settingsIdentical)
2084
+ return null;
2085
+ settings.hooks = merged;
2086
+ if (!settingsIdentical) {
2087
+ if (read.existed)
2088
+ backupConfig(settingsPath);
2089
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2090
+ }
2091
+ return existed ? "updated" : "created";
2092
+ }
2029
2093
  export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
2030
2094
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2031
2095
  const hookPath = path.join(hookDir, "vexp-hint.sh");
@@ -2100,10 +2164,33 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2100
2164
  const script = vexpHintHookScript(binaryPath);
2101
2165
  fs.mkdirSync(dir, { recursive: true });
2102
2166
  const existed = fs.existsSync(hookPath);
2103
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2167
+ let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2104
2168
  if (!scriptIdentical) {
2105
2169
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
2106
2170
  }
2171
+ // Windows gets a batch twin. Codex runs a hook command through
2172
+ // `cmd.exe /C` (COMSPEC, codex-rs/hooks command_runner), so `bash "..."`
2173
+ // reaches a shell that has never heard of bash: a user with neither Git
2174
+ // Bash nor WSL got "bash not found" on every prompt and orientation was
2175
+ // inert. Codex's own answer is `commandWindows`, an optional per-OS
2176
+ // override on the command handler (openai/codex#22159, ~0.131, months
2177
+ // before the version that requires the nested `hooks` shape we already
2178
+ // write). Older builds ignore the field rather than reject the file —
2179
+ // the handler is not deny_unknown_fields, unlike the top level.
2180
+ const cmdPath = path.join(dir, "vexp-hint.cmd");
2181
+ let cmdCommand;
2182
+ if (process.platform === "win32") {
2183
+ const cmdScript = vexpHintHookCmdScript(binaryPath);
2184
+ const cmdExisted = fs.existsSync(cmdPath);
2185
+ if (!cmdExisted || fs.readFileSync(cmdPath, "utf-8") !== cmdScript) {
2186
+ fs.writeFileSync(cmdPath, cmdScript);
2187
+ scriptIdentical = false;
2188
+ }
2189
+ // Quoted: Codex hands `cmd /C` the whole line wrapped in one more pair
2190
+ // of quotes, and cmd then strips the outermost pair — which leaves this
2191
+ // path quoted and therefore safe to contain spaces.
2192
+ cmdCommand = `"${cmdPath}"`;
2193
+ }
2107
2194
  let root = {};
2108
2195
  if (fs.existsSync(hooksJsonPath)) {
2109
2196
  try {
@@ -2141,6 +2228,7 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2141
2228
  {
2142
2229
  type: "command",
2143
2230
  command: `bash "${hookPath.replace(/\\/g, "/")}"`,
2231
+ ...(cmdCommand ? { commandWindows: cmdCommand } : {}),
2144
2232
  timeout: 5,
2145
2233
  },
2146
2234
  ],
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
11
  import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
- import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
13
+ import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, tryOnlineRefresh, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
15
15
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
16
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
@@ -1057,6 +1057,18 @@ program
1057
1057
  console.log(` Plan: ${claims.plan}`);
1058
1058
  console.log(` Email: ${claims.sub}`);
1059
1059
  console.log(` Expires: ${new Date(claims.exp * 1000).toLocaleDateString()}`);
1060
+ // Register this machine NOW, while the user is watching, instead of
1061
+ // whenever a later command happens to fall outside the 24h refresh
1062
+ // window. The dashboard tells people to "open vexp on any machine to
1063
+ // register it" and activating IS that — a tier-4 user read 0 of 10
1064
+ // devices immediately after activating and reasonably thought something
1065
+ // had failed. Best effort by design: the refresh has its own short
1066
+ // timeout and never throws, so an offline activation still succeeds and
1067
+ // simply registers later.
1068
+ try {
1069
+ await tryOnlineRefresh(key.trim());
1070
+ }
1071
+ catch { /* offline or server down: registration happens on a later run */ }
1060
1072
  console.log("");
1061
1073
  }
1062
1074
  catch (err) {
@@ -1076,15 +1088,28 @@ program
1076
1088
  .description("Show current license status")
1077
1089
  .action(() => {
1078
1090
  const limits = readLicenseLimits();
1091
+ // AppSumo lifetime tiers print as the raw internal string ("tier3") with a
1092
+ // "Renews" date one month out — which is the local token's expiry, not the
1093
+ // licence's. It rolls forward by itself on every check-in. A tier-3 buyer
1094
+ // read that as a monthly deadline and re-activated his key by hand every
1095
+ // month for nothing, and he was right to be alarmed by what it said.
1096
+ const ltd = /^tier[1-4]$/.test(limits.plan);
1097
+ const planLabel = ltd
1098
+ ? `Lifetime · Tier ${limits.plan.slice(4)}`
1099
+ : limits.plan;
1079
1100
  console.log(chalk.bold("\nvexp License Status\n"));
1080
- console.log(` Plan: ${limits.plan}`);
1101
+ console.log(` Plan: ${planLabel}`);
1081
1102
  if (limits.email)
1082
1103
  console.log(` Email: ${limits.email}`);
1083
1104
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1084
1105
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1085
1106
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1086
- if (limits.renewsAt)
1087
- console.log(` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1107
+ if (limits.renewsAt) {
1108
+ console.log(ltd
1109
+ ? ` Renewal: none — lifetime licence (local token auto-refreshes, ` +
1110
+ `current one valid to ${limits.renewsAt.toLocaleDateString()})`
1111
+ : ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1112
+ }
1088
1113
  const blocked = readDeviceBlocked();
1089
1114
  if (blocked) {
1090
1115
  console.log("");
@@ -1708,14 +1733,20 @@ async function executeCommand(label, rl) {
1708
1733
  // ── License commands ──
1709
1734
  case "status": {
1710
1735
  const limits = readLicenseLimits();
1711
- console.log(` Plan: ${limits.plan}`);
1736
+ // Same wording as `vexp license`: a lifetime tier must not be shown as
1737
+ // a raw "tier3" with a monthly renewal date it does not have.
1738
+ const ltd = /^tier[1-4]$/.test(limits.plan);
1739
+ console.log(` Plan: ${ltd ? `Lifetime · Tier ${limits.plan.slice(4)}` : limits.plan}`);
1712
1740
  if (limits.email)
1713
1741
  console.log(` Email: ${limits.email}`);
1714
1742
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
1715
1743
  console.log(` Max repos: ${limits.maxRepos === 0 ? "unlimited" : limits.maxRepos}`);
1716
1744
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
1717
- if (limits.renewsAt)
1718
- console.log(` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1745
+ if (limits.renewsAt) {
1746
+ console.log(ltd
1747
+ ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
1748
+ : ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1749
+ }
1719
1750
  break;
1720
1751
  }
1721
1752
  case "activate": {
package/dist/doctor.js CHANGED
@@ -103,6 +103,47 @@ function isAlive(pid) {
103
103
  return false;
104
104
  }
105
105
  }
106
+ /**
107
+ * The git-hooks verdict, as data.
108
+ *
109
+ * Extracted because it was wrong in a way no test could catch: an overriding
110
+ * `core.hooksPath` was reported as a FAILURE claiming "the index does not
111
+ * refresh on commit/merge/checkout", and the remedy offered was to edit a
112
+ * file that moon, husky and lefthook all regenerate. The claim is untrue —
113
+ * the daemon watches the tree live and reconciles it against disk every five
114
+ * minutes — and the remedy was unusable. A user spent a round trip on it.
115
+ */
116
+ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
117
+ const ourHooksDir = path.join(repoRoot, ".git", "hooks");
118
+ if (!hooksPath) {
119
+ if (installedCount === 3) {
120
+ return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
121
+ }
122
+ if (installedCount === 0) {
123
+ return { level: OK, message: "no vexp git hooks (index refreshes on demand)" };
124
+ }
125
+ return {
126
+ level: WARN,
127
+ message: `only ${installedCount}/3 vexp git hooks present — re-run 'vexp hooks install'`,
128
+ };
129
+ }
130
+ const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(repoRoot, hooksPath);
131
+ if (path.resolve(resolved) === path.resolve(ourHooksDir)) {
132
+ return {
133
+ level: OK,
134
+ message: `core.hooksPath points at .git/hooks (${installedCount}/3 vexp hooks present)`,
135
+ };
136
+ }
137
+ if (installedCount === 0) {
138
+ return { level: OK, message: `core.hooksPath = ${resolved} (no vexp git hooks installed here)` };
139
+ }
140
+ return {
141
+ level: WARN,
142
+ message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in .git/hooks never run.\n` +
143
+ ` the index still refreshes: the daemon watches the tree live and reconciles it against disk every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).\n` +
144
+ ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
145
+ };
146
+ }
106
147
  export async function runDoctor() {
107
148
  const home = vexpHome();
108
149
  let warns = 0;
@@ -149,7 +190,26 @@ export async function runDoctor() {
149
190
  : st.compressor ?? "unknown (older daemon)";
150
191
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
151
192
  if (st.llm_configured_but_inactive === true) {
152
- line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
193
+ // The daemon knows WHY, and doctor was throwing it away: it printed
194
+ // the same "restart to load the model" the sidebar prints, to a user
195
+ // whose model fails on every start. Restarting is advice only when
196
+ // the cause can change between starts.
197
+ const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
198
+ const restartIsPointless = why !== undefined &&
199
+ /vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
200
+ // A daemon still running its startup sync has not REACHED the model
201
+ // yet. Restarting there is worse than useless: it starts the sync
202
+ // over, and the user never arrives.
203
+ const stillStarting = why !== undefined && /still completing its startup/i.test(why);
204
+ line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
205
+ (why ? `\n reason: ${why}` : "") +
206
+ (stillStarting
207
+ ? `\n wait for the sync to finish — do NOT restart, that begins it again` +
208
+ `\n the daemon logs 'LlmWorker active' when the model is up`
209
+ : restartIsPointless
210
+ ? `\n restarting will NOT change this — fix the cause above`
211
+ : `\n run 'vexp daemon-cmd restart' to load the model`) +
212
+ `\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`);
153
213
  }
154
214
  // 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
155
215
  // serving the OLD feature set and its gaps read as product bugs
@@ -435,27 +495,8 @@ export async function runDoctor() {
435
495
  return false;
436
496
  }
437
497
  });
438
- if (!hooksPath) {
439
- if (installed.length === 3)
440
- line(OK, "vexp hooks present in .git/hooks and git will run them");
441
- else if (installed.length === 0)
442
- line(OK, "no vexp git hooks (index refreshes on demand)");
443
- else
444
- line(WARN, `only ${installed.length}/3 vexp git hooks present — re-run 'vexp hooks install'`);
445
- }
446
- else {
447
- const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(ws.root, hooksPath);
448
- const sameDir = path.resolve(resolved) === path.resolve(ours);
449
- if (sameDir) {
450
- line(OK, `core.hooksPath points at .git/hooks (${installed.length}/3 vexp hooks present)`);
451
- }
452
- else if (installed.length > 0) {
453
- line(BAD, `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installed.length} vexp hook(s) in .git/hooks NEVER run and the index does not refresh on commit/merge/checkout. Add 'vexp index --finalize || true' to ${path.join(resolved, "pre-commit")}, or 'git config --unset core.hooksPath' for this repo.`);
454
- }
455
- else {
456
- line(OK, `core.hooksPath = ${resolved} (no vexp git hooks installed here)`);
457
- }
458
- }
498
+ const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
499
+ line(verdict.level, verdict.message);
459
500
  }
460
501
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
461
502
  {
@@ -527,6 +568,106 @@ export async function runDoctor() {
527
568
  }
528
569
  }
529
570
  }
571
+ // 5b-bis) Codex orientation hook. The section above it reports Codex's MCP
572
+ // config and stops there, so a Codex user could have half the product wired
573
+ // and read nothing but [OK]: the tools come from ~/.codex/config.toml, the
574
+ // per-prompt orientation comes from .codex/hooks.json, and until now only
575
+ // the first was ever checked. Two users reached that blind spot from
576
+ // opposite directions in one week — one could not tell whether orientation
577
+ // was configured, the other could not tell whether it ran.
578
+ console.log(chalk.bold("\nCodex orientation hook (.codex/hooks.json)"));
579
+ {
580
+ const hooksJson = path.join(ws.root, ".codex", "hooks.json");
581
+ const scriptPath = path.join(ws.root, ".codex", "vexp-hint.sh");
582
+ const codexMcp = (() => {
583
+ try {
584
+ return fs
585
+ .readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf-8")
586
+ .includes("vexp");
587
+ }
588
+ catch {
589
+ return false;
590
+ }
591
+ })();
592
+ let cfg = null;
593
+ try {
594
+ cfg = JSON.parse(fs.readFileSync(hooksJson, "utf-8"));
595
+ }
596
+ catch { /* absent */ }
597
+ if (!cfg) {
598
+ // Only a finding for someone who actually uses Codex here.
599
+ line(codexMcp ? WARN : OK, codexMcp
600
+ ? "no .codex/hooks.json, but vexp MCP is configured for Codex — the tools work and the per-prompt orientation was never installed. Run 'vexp setup' here (needs Codex >= 0.129)."
601
+ : "no .codex/hooks.json (Codex not configured here)");
602
+ }
603
+ else {
604
+ // Two shapes exist in the wild: the installer nests under "hooks", and
605
+ // files from earlier versions (or edited by hand) carry the event at the
606
+ // top level. Reading only one of them would leave the other undiagnosable
607
+ // — and the flat one is what the user who reported this had on disk.
608
+ const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
609
+ ? cfg.hooks.UserPromptSubmit
610
+ : Array.isArray(cfg?.UserPromptSubmit)
611
+ ? cfg.UserPromptSubmit
612
+ : []);
613
+ const hook = events
614
+ .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
615
+ .find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
616
+ if (!hook) {
617
+ line(WARN, "hooks.json has no vexp UserPromptSubmit entry — re-run 'vexp setup'.");
618
+ }
619
+ else if (!fs.existsSync(scriptPath)) {
620
+ line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
621
+ }
622
+ else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
623
+ // On Windows Codex runs the command through cmd.exe, which has no
624
+ // bash: without the commandWindows override the entry is inert on
625
+ // every prompt unless Git Bash happens to be on PATH. Probing it
626
+ // through bash here would hide exactly that.
627
+ line(BAD, "the entry has no 'commandWindows' — Codex runs hooks through cmd.exe on Windows, which cannot run the bash script, so orientation never fires (unless Git Bash is on PATH). Re-run 'vexp setup' to write the batch twin.");
628
+ }
629
+ else {
630
+ // Run it, through the same shell Codex would use: cmd.exe on
631
+ // Windows (COMSPEC), sh elsewhere. The script bakes an absolute path
632
+ // to the vexp binary and exits 0 when that path is not executable, so
633
+ // a stale or wrong-profile path leaves NO trace anywhere: no
634
+ // orientation, no error, forever. A Windows user found exactly that
635
+ // by reading the generated file.
636
+ const isWin = process.platform === "win32";
637
+ const cmdLine = (isWin ? hook.commandWindows : hook.command);
638
+ const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
639
+ env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
640
+ input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
641
+ timeout: 10000,
642
+ encoding: "utf-8",
643
+ });
644
+ const baked = (() => {
645
+ try {
646
+ // One pattern covers both twins: VEXP_BIN="path" (bash) and
647
+ // set "VEXP_BIN=path" (cmd).
648
+ const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
649
+ return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
650
+ }
651
+ catch {
652
+ return undefined;
653
+ }
654
+ })();
655
+ if (r.error) {
656
+ line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
657
+ }
658
+ else if (r.status !== 0) {
659
+ line(BAD, `hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Codex continues without vexp.`);
660
+ }
661
+ else if (baked && !fs.existsSync(baked)) {
662
+ line(BAD, `hook runs but the binary it points at does not exist: ${baked} — it exits silently on every prompt. Re-run 'vexp setup' as the user that owns this install.`);
663
+ }
664
+ else {
665
+ const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
666
+ line(OK, `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
667
+ }
668
+ }
669
+ }
670
+ }
530
671
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
531
672
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
532
673
  // cannot spawn silently enforces nothing there as well. The guard's stdin
@@ -397,6 +397,55 @@ exit 0
397
397
  export function vexpHintHookScript(binaryPath) {
398
398
  return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
399
399
  }
400
+ /**
401
+ * Windows twin of the hint hook. Codex runs a hook command through
402
+ * `cmd.exe /C` (COMSPEC) on Windows, and cmd has no `bash`: a Windows user
403
+ * with neither Git Bash nor WSL got "bash not found" on every prompt, so
404
+ * orientation was inert and nothing said so. The logic is four lines and
405
+ * none of it is shell-specific, so it translates exactly — and `exit /b 0`
406
+ * on the last line keeps the same fail-open contract as the bash twin.
407
+ *
408
+ * CRLF on purpose: a batch file with bare LF endings is parsed
409
+ * inconsistently by cmd, and this one has to run on the machines least
410
+ * likely to have a POSIX toolchain that would normalise it.
411
+ */
412
+ export const VEXP_HINT_HOOK_CMD = [
413
+ "@echo off",
414
+ "REM vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.",
415
+ 'set "VEXP_BIN=__VEXP_BIN__"',
416
+ 'if not exist "%VEXP_BIN%" exit /b 0',
417
+ '"%VEXP_BIN%" prompt-hint 2>nul',
418
+ "exit /b 0",
419
+ "",
420
+ ].join("\r\n");
421
+ /** Bake the binary path into the Windows hint hook. Backslashes stay. */
422
+ export function vexpHintHookCmdScript(binaryPath) {
423
+ return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", binaryPath.replace(/\//g, "\\"));
424
+ }
425
+ /**
426
+ * v4 M1: the search answer (PreToolUse on Bash).
427
+ *
428
+ * Of 227 measured agent sessions with vexp installed, 9 called it — 4%. Bash
429
+ * is called in all of them. So this stops asking the model to adopt anything:
430
+ * when it is about to grep the repository for a name the index knows, the
431
+ * command is rewritten to ask vexp first, with the original grep preserved
432
+ * after a `||` so the failure mode is exactly "vexp is not installed".
433
+ *
434
+ * All the logic is in the binary (`vexp-core search-hook`), like prompt-hint:
435
+ * the script is a cross-OS wrapper that exits silently when anything is
436
+ * missing.
437
+ */
438
+ export const VEXP_SEARCH_HOOK = `#!/bin/bash
439
+ # vexp-search: answer a grep with structure instead of lines. Fails open.
440
+ VEXP_BIN="__VEXP_BIN__"
441
+ [ -x "$VEXP_BIN" ] || exit 0
442
+ "$VEXP_BIN" search-hook 2>/dev/null
443
+ exit 0
444
+ `;
445
+ /** Bake the binary path into the search hook script. */
446
+ export function vexpSearchHookScript(binaryPath) {
447
+ return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
448
+ }
400
449
  /**
401
450
  * Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
402
451
  * in the Rust binary (stop-gate): mechanical completion check via daemon,
package/dist/license.js CHANGED
@@ -71,6 +71,15 @@ function readLastCheck() {
71
71
  return 0;
72
72
  }
73
73
  }
74
+ /** Forget when we last checked in, so the next refresh is not throttled. */
75
+ export function clearLastCheck() {
76
+ try {
77
+ fs.rmSync(getLastCheckPath(), { force: true });
78
+ }
79
+ catch {
80
+ /* best effort: a stale stamp only delays registration, never breaks it */
81
+ }
82
+ }
74
83
  function writeLastCheck(ts) {
75
84
  try {
76
85
  fs.mkdirSync(path.dirname(getLastCheckPath()), { recursive: true });
@@ -289,6 +298,14 @@ export function activateLicense(jwt) {
289
298
  // leaving it would shadow the just-activated tier until it expired. The next
290
299
  // online refresh re-creates fresh.jwt at the new tier.
291
300
  removeFreshToken();
301
+ // Activation is the one moment we KNOW the user is at this machine, so it is
302
+ // the moment to register it. Clearing the throttle stamp makes the very next
303
+ // refresh run instead of waiting out the 24h backoff — without it the device
304
+ // does not appear on the dashboard until some later command happens to fall
305
+ // outside the window, and the page meanwhile says "Open vexp on any machine
306
+ // to register it" to someone who just did exactly that. Reported by a tier-4
307
+ // user whose dashboard read 0 of 10 right after activating.
308
+ clearLastCheck();
292
309
  return claims;
293
310
  }
294
311
  /** Remove the current license file (and any cached freshToken) */