vexp-cli 3.2.5 → 3.3.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.
@@ -12,7 +12,8 @@ import * as os from "os";
12
12
  import { spawnSync } from "child_process";
13
13
  import * as crypto from "crypto";
14
14
  import { canonicalWorkspaceRoot } from "./socket-path.js";
15
- import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook } from "./hook-template.js";
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
17
  // ---------------------------------------------------------------------------
17
18
  // Constants
18
19
  // ---------------------------------------------------------------------------
@@ -116,10 +117,29 @@ export function interventionMode() {
116
117
  // ---------------------------------------------------------------------------
117
118
  // Agent detectors - mirrors VS Code extension's AGENT_DETECTORS
118
119
  // ---------------------------------------------------------------------------
120
+ /**
121
+ * What Codex itself leaves under `~/.codex`: the login, the session store,
122
+ * the history. Any one of them means Codex has run on this machine. The
123
+ * folder alone does not: configureCodexGlobal creates it for config.toml.
124
+ * Lockstep with the extension.
125
+ */
126
+ function codexHomeArtefacts() {
127
+ // CODEX_HOME is where Codex keeps all of it when set (Codex's documented
128
+ // relocation); the home folder alone missed such a user, and the
129
+ // extension's orphan sweep then took his working hooks for leftovers.
130
+ const codexHome = process.env.CODEX_HOME ? path.resolve(process.env.CODEX_HOME) : path.join(os.homedir(), ".codex");
131
+ return ["auth.json", "sessions", "session_index.jsonl", "history.jsonl", "installation_id", "version.json"].map((f) => path.join(codexHome, f));
132
+ }
119
133
  const AGENT_DETECTORS = [
120
134
  {
121
135
  agent: "Claude Code",
122
136
  detectPath: ".claude",
137
+ // Machine marker: the ~/.claude DIRECTORY, which only Claude Code
138
+ // creates. Never ~/.claude.json — configureClaudeCodeGlobal writes that
139
+ // file itself, so it would keep Claude Code "found" forever. Without a
140
+ // machine marker the one tool a user had needed Setup Agents while two
141
+ // he did not have were set up on their own (field report, 2026-09-24).
142
+ detectHome: ".claude",
123
143
  configFile: ".claude/CLAUDE.md",
124
144
  templateName: "claude-code",
125
145
  // MCP configured in ~/.claude.json (user-scope) via configureClaudeCodeGlobal()
@@ -185,7 +205,19 @@ const AGENT_DETECTORS = [
185
205
  },
186
206
  {
187
207
  agent: "Codex",
188
- detectPath: "AGENTS.md",
208
+ // Codex's own footprint: the login, sessions and history it keeps under
209
+ // `~/.codex` (see codexHomeArtefacts), or a project-level config Codex
210
+ // itself keeps. Nothing vexp writes is a marker: not AGENTS.md (written
211
+ // for Antigravity, opencode and ZCode too), not the `.codex/` folder our
212
+ // hooks.json lands in, and not `~/.codex` itself, which
213
+ // configureCodexGlobal creates for its config.toml; any of those would
214
+ // keep Codex "detected" forever on a machine that never had it (field
215
+ // report, 2026-09-24). The ~/.codex footprint is a machine-only marker:
216
+ // it says Codex ran on this PC weeks ago, not that this project uses it,
217
+ // so it gets Codex set up here only once the user says so
218
+ // (planAgentSetup). Lockstep with the extension.
219
+ detectPath: ".codex/config.toml",
220
+ detectAbsPaths: codexHomeArtefacts,
189
221
  configFile: "AGENTS.md",
190
222
  templateName: "agents-md",
191
223
  // MCP configured in ~/.codex/config.toml (global) via configureCodexGlobal()
@@ -266,13 +298,152 @@ const AGENT_DETECTORS = [
266
298
  // same mandate every AGENTS.md agent gets, under a name of its own. It
267
299
  // connects to vexp on its side, so there is no MCP file to write here:
268
300
  // the instructions are the whole integration. Detected by the file
269
- // itself, as Codex is by AGENTS.md.
301
+ // itself (Codex, by contrast, by its own ~/.codex footprint).
270
302
  agent: "Friday Code",
271
303
  detectPath: "friday.md",
272
304
  configFile: "friday.md",
273
305
  templateName: "agents-md",
274
306
  },
275
307
  ];
308
+ /**
309
+ * Every file vexp writes as an agent's instructions. As a detection marker
310
+ * such a file proves only that vexp ran here: a `.clinerules` written because
311
+ * Cline's VS Code storage exists "detected" Cline in that project at every
312
+ * start after, the loop AGENTS.md closed for Codex through 3.2.5. It counts
313
+ * only when someone put content of their own in it.
314
+ */
315
+ const VEXP_WRITTEN_MARKERS = new Set(AGENT_DETECTORS.map((d) => d.configFile));
316
+ /** `text` without vexp's `<!-- vexp ... -->` … `<!-- /vexp -->` block (to
317
+ * the end of the file for the old unterminated format). */
318
+ function withoutVexpSection(text) {
319
+ const m = VEXP_MARKER_RE.exec(text);
320
+ if (!m)
321
+ return text;
322
+ let start = m.index;
323
+ while (start > 0 && text[start - 1] !== "\n")
324
+ start--;
325
+ const endIdx = text.indexOf(VEXP_MARKER_END, m.index);
326
+ const end = endIdx === -1 ? text.length : endIdx + VEXP_MARKER_END.length;
327
+ return text.slice(0, start) + text.slice(end);
328
+ }
329
+ /** Does a vexp-written marker hold anything of the user's? Unreadable counts
330
+ * as yes: when in doubt, detection behaves as it always did. */
331
+ function hasOwnContent(p) {
332
+ let st;
333
+ try {
334
+ st = fs.statSync(p);
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ if (st.isDirectory()) {
340
+ // appendOrCreate writes `<dir>/vexp.md` when the target is a directory.
341
+ let entries;
342
+ try {
343
+ entries = fs.readdirSync(p);
344
+ }
345
+ catch {
346
+ return true;
347
+ }
348
+ return entries.some((e) => e !== "vexp.md" || hasOwnContent(path.join(p, e)));
349
+ }
350
+ try {
351
+ return withoutVexpSection(fs.readFileSync(p, "utf-8")).trim() !== "";
352
+ }
353
+ catch {
354
+ return true;
355
+ }
356
+ }
357
+ function isDirectory(p) {
358
+ try {
359
+ return fs.statSync(p).isDirectory();
360
+ }
361
+ catch {
362
+ return false;
363
+ }
364
+ }
365
+ /** Where `d` is found for this workspace, if anywhere. */
366
+ export function classifyAgent(d, workspaceRoot) {
367
+ const markers = [d.detectPath, ...(d.detectPaths ?? [])];
368
+ const inProject = markers.some((m) => {
369
+ const p = path.join(workspaceRoot, m);
370
+ if (!fs.existsSync(p))
371
+ return false;
372
+ return !VEXP_WRITTEN_MARKERS.has(m) || hasOwnContent(p);
373
+ });
374
+ if (inProject)
375
+ return "project";
376
+ if (d.detectHome && isDirectory(path.join(os.homedir(), d.detectHome)))
377
+ return "machine";
378
+ if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p)))
379
+ return "machine";
380
+ return undefined;
381
+ }
382
+ /** Every agent found for this workspace, in detector order. */
383
+ export function classifyAgents(workspaceRoot) {
384
+ const out = [];
385
+ for (const d of AGENT_DETECTORS) {
386
+ const kind = classifyAgent(d, workspaceRoot);
387
+ if (kind)
388
+ out.push({ agent: d.agent, kind });
389
+ }
390
+ return out;
391
+ }
392
+ /**
393
+ * What a setup does without asking. Project markers and earlier "yes"
394
+ * answers are configured as they always were; traces on the machine alone
395
+ * (Codex's login, Antigravity's folder, Cline's storage, ~/.claude) wait for
396
+ * the user. Nothing vexp wrote counts as an answer: a 3.2.5 user whose Codex
397
+ * setup came from AGENTS.md is asked once, like everyone else.
398
+ */
399
+ export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
400
+ const plan = { configure: [], offer: [], declined: [], kinds: {} };
401
+ const key = consentKey(workspaceRoot);
402
+ for (const d of AGENT_DETECTORS) {
403
+ const kind = classifyAgent(d, workspaceRoot);
404
+ if (kind)
405
+ plan.kinds[d.agent] = kind;
406
+ const decision = decisionForKey(consent, key, d.agent);
407
+ if (kind === "project" || decision === "yes")
408
+ plan.configure.push(d.agent);
409
+ else if (kind === "machine")
410
+ (decision === "no" || decision === "never" ? plan.declined : plan.offer).push(d.agent);
411
+ }
412
+ return plan;
413
+ }
414
+ /** The command that configures `agents` here and does nothing else: `vexp
415
+ * setup` without --no-index indexes the project again first. */
416
+ export function setupAgentsCommand(agents, dir) {
417
+ return `vexp setup ${dir ? `"${dir}" ` : ""}--no-index --agents "${agents.join(",")}"`;
418
+ }
419
+ /**
420
+ * `vexp setup` with no one at the keyboard (an agent following doctor's
421
+ * advice, a script): the agents it sets up, the lines it prints about the
422
+ * ones it leaves alone, and whether it found no agent at all, the one case
423
+ * that exits 1. It used to exit 1 also when every agent found was only on
424
+ * the machine and waiting for a choice, right after naming the command that
425
+ * adds them, and that command indexed the project again (review of the
426
+ * 2026-09-24 fix). A tool waiting for a choice, or declined, is left alone
427
+ * as the user wants: nothing to configure then is no failure.
428
+ */
429
+ export function nonInteractiveAgentChoice(plan, dir) {
430
+ const notes = [];
431
+ if (plan.offer.length > 0) {
432
+ notes.push(`Found on this machine, not set up for this project: ${plan.offer.join(", ")}.`);
433
+ notes.push(`To set ${plan.offer.length === 1 ? "it" : "them"} up here: ${setupAgentsCommand(plan.offer, dir)}`);
434
+ }
435
+ if (plan.configure.length === 0) {
436
+ if (plan.declined.length > 0)
437
+ notes.push(`Not set up, as answered earlier: ${plan.declined.join(", ")}.`);
438
+ if (plan.offer.length > 0 || plan.declined.length > 0)
439
+ notes.push("No agent set up for this project.");
440
+ }
441
+ return {
442
+ selected: [...plan.configure],
443
+ notes,
444
+ noAgentFound: plan.configure.length === 0 && plan.offer.length === 0 && plan.declined.length === 0,
445
+ };
446
+ }
276
447
  // ---------------------------------------------------------------------------
277
448
  // Public API
278
449
  // ---------------------------------------------------------------------------
@@ -309,8 +480,9 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
309
480
  }
310
481
  break;
311
482
  case "Codex":
312
- out.push("~/.codex/config.toml or .codex/config.toml (MCP server entry)");
313
- out.push(".codex/vexp-hint.sh + .codex/hooks.json hooks.UserPromptSubmit (orientation)");
483
+ // Only the user config: the entry names no project, so one serves them all.
484
+ out.push("~/.codex/config.toml [mcp_servers.vexp] (MCP server entry, machine-wide)");
485
+ out.push(`.codex/vexp-hint.sh${process.platform === "win32" ? " + .codex/vexp-hint.cmd" : ""} + .codex/hooks.json hooks.UserPromptSubmit (orientation; approve it once in codex with /hooks)`);
314
486
  break;
315
487
  case "Cline":
316
488
  out.push("cline_mcp_settings.json in each installed VS Code variant (MCP, machine-global)");
@@ -356,37 +528,29 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
356
528
  return out;
357
529
  }
358
530
  /**
359
- * Detect which AI coding agents are present in the workspace.
531
+ * Detect which AI coding agents are present — in the workspace or, for the
532
+ * machine-only markers, on this machine (see classifyAgent).
360
533
  */
361
534
  export function detectAgents(workspaceRoot) {
362
- return AGENT_DETECTORS.filter((d) => {
363
- const markers = [d.detectPath, ...(d.detectPaths ?? [])];
364
- if (markers.some((m) => fs.existsSync(path.join(workspaceRoot, m)))) {
365
- return true;
366
- }
367
- // Home-level marker: the IDE is installed on this machine even though
368
- // the workspace carries no footprint (e.g. Antigravity).
369
- if (d.detectHome && fs.existsSync(path.join(os.homedir(), d.detectHome))) {
370
- return true;
371
- }
372
- if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p))) {
373
- return true;
374
- }
375
- return false;
376
- });
535
+ return AGENT_DETECTORS.filter((d) => classifyAgent(d, workspaceRoot) !== undefined);
377
536
  }
378
537
  /**
379
- * Configure all detected agents: write instruction files + MCP configs.
380
- * If `agentFilter` is provided, only configure those agents.
538
+ * Configure agents: write instruction files + MCP configs. With
539
+ * `agentFilter`, the detected agents it names; without, what
540
+ * planAgentSetup configures without asking.
381
541
  */
382
542
  export function configureAgents(workspaceRoot, binaryPath, version, agentFilter, mcpServerPath) {
383
543
  const results = [];
384
544
  const mcpConfigs = [];
385
545
  const writtenConfigFiles = new Set();
386
- let agents = detectAgents(workspaceRoot);
546
+ let agents;
387
547
  if (agentFilter && agentFilter.length > 0) {
388
548
  const filter = agentFilter.map((a) => a.toLowerCase());
389
- agents = agents.filter((d) => filter.includes(d.agent.toLowerCase()));
549
+ agents = detectAgents(workspaceRoot).filter((d) => filter.includes(d.agent.toLowerCase()));
550
+ }
551
+ else {
552
+ const plan = planAgentSetup(workspaceRoot);
553
+ agents = AGENT_DETECTORS.filter((d) => plan.configure.includes(d.agent));
390
554
  }
391
555
  for (const detected of agents) {
392
556
  const detector = withCursorTarget(detected, workspaceRoot);
@@ -473,7 +637,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
473
637
  }
474
638
  // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
475
639
  if (detector.agent === "Claude Code") {
476
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
640
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
477
641
  if (wrote)
478
642
  mcpConfigs.push("~/.claude.json");
479
643
  // Event-driven hint hook: default-on (non-blocking, fail-open — the
@@ -850,7 +1014,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
850
1014
  removeDeadWindsurfMcpJson(workspaceRoot);
851
1015
  }
852
1016
  if (detector.agent === "Claude Code") {
853
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
1017
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
854
1018
  if (wrote)
855
1019
  mcpConfigs.push("~/.claude.json");
856
1020
  installClaudeCodeHintHook(workspaceRoot, binaryPath);
@@ -1226,6 +1390,50 @@ export function isBareInterpreter(entry) {
1226
1390
  function adoptableEntry(entry) {
1227
1391
  return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
1228
1392
  }
1393
+ /**
1394
+ * Is `recorded` strictly older than `mine`? Copy of the extension's
1395
+ * isOlderVexpVersion (itself one of several: mcp-supervisor.ts carries the
1396
+ * tests of the order). Unparseable is never older: such an entry is kept.
1397
+ */
1398
+ function isOlderVexpVersion(recorded, mine) {
1399
+ if (recorded === mine)
1400
+ return false;
1401
+ const parts = (v) => {
1402
+ const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
1403
+ return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
1404
+ };
1405
+ const a = parts(recorded);
1406
+ const b = parts(mine);
1407
+ if (!a || !b)
1408
+ return false;
1409
+ for (let i = 0; i < 3; i++) {
1410
+ if (a[i] !== b[i])
1411
+ return a[i] < b[i];
1412
+ }
1413
+ return false;
1414
+ }
1415
+ /** The extension version an MCP entry points into (`vexp.vexp-vscode-3.2.5`,
1416
+ * with or without a platform suffix), or undefined when it is not one of
1417
+ * the extension's folders. Lockstep with the extension. */
1418
+ function vexpInstallVersion(entry) {
1419
+ if (!entry || typeof entry !== "object")
1420
+ return undefined;
1421
+ const e = entry;
1422
+ const parts = [];
1423
+ if (typeof e.command === "string")
1424
+ parts.push(e.command);
1425
+ if (Array.isArray(e.args)) {
1426
+ for (const a of e.args)
1427
+ if (typeof a === "string")
1428
+ parts.push(a);
1429
+ }
1430
+ for (const p of parts) {
1431
+ const m = p.match(/vexp\.vexp-vscode-(\d+\.\d+\.\d+)/);
1432
+ if (m)
1433
+ return m[1];
1434
+ }
1435
+ return undefined;
1436
+ }
1229
1437
  function vexpEntryStillResolves(entry) {
1230
1438
  if (!entry || typeof entry !== "object")
1231
1439
  return false;
@@ -1353,6 +1561,38 @@ const unreachableTargets = [];
1353
1561
  export function takeUnreachableTargets() {
1354
1562
  return unreachableTargets.splice(0, unreachableTargets.length);
1355
1563
  }
1564
+ /**
1565
+ * Projects whose Codex hook was written for the first time, or whose hook
1566
+ * definition changed, in this run. Codex runs a project hook only once the
1567
+ * user has approved that exact definition (a trust record in its own
1568
+ * config.toml, keyed by the hook's position and hashed over its command);
1569
+ * vexp never writes that record for them. Nothing in the project shows the
1570
+ * hook is waiting for that, so the step has to be said where the hook was
1571
+ * written.
1572
+ */
1573
+ const codexHookApprovals = [];
1574
+ /** Drain the list of projects whose Codex hook needs the user's approval. */
1575
+ export function takeCodexHookApprovals() {
1576
+ return codexHookApprovals.splice(0, codexHookApprovals.length);
1577
+ }
1578
+ /**
1579
+ * The approval step, in the words the VS Code extension shows too
1580
+ * (agent-auto-config.ts codexHookApprovalStep, lockstep-tested). ASCII only.
1581
+ *
1582
+ * Both places that approve a hook are named: the Codex extension for VS Code
1583
+ * has its own Trust action (Settings > Hooks), and a terminal has /hooks. On
1584
+ * Windows an approval in one does not reach the other: Codex keys the record
1585
+ * by the folder as the session spells it, `C:\` in a terminal and `c:\` in
1586
+ * the VS Code extension (codex.exe 0.155, hooks/list).
1587
+ */
1588
+ export function codexHookApprovalStep(workspaceRoot, platform = process.platform) {
1589
+ const step = "Codex runs vexp's orientation hook only after you approve it once. " +
1590
+ "In the Codex extension for VS Code: open the Codex panel > Settings > Hooks and trust vexp-hint. " +
1591
+ `In a terminal: run codex in ${workspaceRoot}, type /hooks and approve vexp-hint (trust the folder first if Codex asks).`;
1592
+ return platform === "win32"
1593
+ ? `${step} On Windows the two keep separate approvals (they spell the drive letter differently), so approve it in each one you use; 'vexp doctor' shows which have it.`
1594
+ : `${step} 'vexp doctor' shows whether Codex has it approved.`;
1595
+ }
1356
1596
  /**
1357
1597
  * Cline keeps its MCP registry inside the VS Code extension's globalStorage,
1358
1598
  * so there is nothing to write until the extension has been installed once.
@@ -1945,6 +2185,10 @@ function readMcpPort(workspaceRoot) {
1945
2185
  // tell OUR config apart from a config the user hand-wrote. Must stay in sync
1946
2186
  // with packages/vexp-vscode/src/providers/agent-auto-config.ts.
1947
2187
  const CODEX_MANAGED_MARKER = "# vexp-managed";
2188
+ // What marks a section `vexp use --pin` pinned on purpose. Setup and every
2189
+ // VS Code start keep such a pin; only `vexp use` (another project, or
2190
+ // --unpin) changes it. Twin: agent-auto-config.ts.
2191
+ const CODEX_USE_PIN_MARK = "pinned by 'vexp use'";
1948
2192
  /** Quote a value as a TOML literal string (single quotes, no escaping) so
1949
2193
  * Windows paths work verbatim. Falls back to a basic escaped string if the
1950
2194
  * value contains a single quote. */
@@ -1982,6 +2226,13 @@ function codexDirectBinary(section) {
1982
2226
  return undefined;
1983
2227
  return cmd;
1984
2228
  }
2229
+ /** The project `vexp use --pin` pinned our section to, when it did. Twin: agent-auto-config.ts. */
2230
+ function explicitCodexPin(section) {
2231
+ if (!section.includes(CODEX_MANAGED_MARKER) || !section.includes(CODEX_USE_PIN_MARK))
2232
+ return undefined;
2233
+ const m = /^\s*VEXP_WORKSPACE\s*=\s*(.+?)\s*$/m.exec(section);
2234
+ return m ? parseTomlString(m[1]) : undefined;
2235
+ }
1985
2236
  function extractCodexVexpSection(content) {
1986
2237
  const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
1987
2238
  return m ? m.join("\n") : "";
@@ -2002,6 +2253,19 @@ export function codexGlobalSectionIsUserManaged() {
2002
2253
  }
2003
2254
  return isUserManagedCodexSection(extractCodexVexpSection(content));
2004
2255
  }
2256
+ /** The project an explicit `vexp use --pin` pinned ~/.codex/config.toml's
2257
+ * vexp entry to, if it did. */
2258
+ export function codexGlobalExplicitPin() {
2259
+ const home = os.homedir();
2260
+ if (!home)
2261
+ return undefined;
2262
+ try {
2263
+ return explicitCodexPin(extractCodexVexpSection(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf-8")));
2264
+ }
2265
+ catch {
2266
+ return undefined;
2267
+ }
2268
+ }
2005
2269
  /** True when the existing [mcp_servers.vexp] section was written by the user
2006
2270
  * (not by vexp) and must NOT be overwritten. */
2007
2271
  function isUserManagedCodexSection(section) {
@@ -2018,7 +2282,19 @@ function isUserManagedCodexSection(section) {
2018
2282
  return false;
2019
2283
  }
2020
2284
  /** Build the canonical [mcp_servers.vexp] section for the chosen transport.
2021
- * Returns null for `direct` when no usable core binary is available. */
2285
+ * Returns null for `direct` when no usable core binary is available.
2286
+ *
2287
+ * The direct section names no project unless `pinTo` says so: Codex starts a
2288
+ * stdio server per thread, in that thread's folder, and restarts it when the
2289
+ * folder changes (codex-rs rmcp-client stdio_server_launcher + codex-mcp
2290
+ * runtime, since openai/codex#19031, 2026-04), and `vexp-core mcp` finds the
2291
+ * project from there. The pin it carried until 3.2.5 named the project set
2292
+ * up LAST, so with two projects Codex queried the wrong index in one of
2293
+ * them. `pinTo` is `vexp use --pin`, on purpose. The http section keeps its
2294
+ * project: the URL routes to one workspace's daemon.
2295
+ *
2296
+ * BYTE-IDENTICAL with the extension's buildCodexSection, marker lines
2297
+ * included, or the two rewrite this machine-wide file in turn. */
2022
2298
  function buildCodexSection(opts) {
2023
2299
  if (opts.transport === "direct") {
2024
2300
  if (!opts.coreBinaryPath)
@@ -2026,18 +2302,21 @@ function buildCodexSection(opts) {
2026
2302
  const lines = [
2027
2303
  "",
2028
2304
  "[mcp_servers.vexp]",
2029
- `${CODEX_MANAGED_MARKER}: direct transport (set VEXP_CODEX_TRANSPORT=http for the HTTP supervisor)`,
2305
+ opts.pinTo
2306
+ ? `${CODEX_MANAGED_MARKER}: direct transport, ${CODEX_USE_PIN_MARK} --pin (run 'vexp use --unpin' to follow each Codex session's folder again)`
2307
+ : `${CODEX_MANAGED_MARKER}: direct transport, started in each Codex session's folder (VEXP_CODEX_TRANSPORT or the vexp.codexMcpTransport setting chooses the transport)`,
2030
2308
  `command = ${tomlString(opts.coreBinaryPath)}`,
2031
- // Pass --workspace explicitly (highest precedence in cmd_mcp) IN ADDITION to
2032
- // the env pin below, so a stale cwd / stale cached env can't drift the child
2033
- // to the wrong repo. JSON.stringify yields a TOML-valid quoted string array.
2034
- `args = ${opts.workspaceRoot ? JSON.stringify(["mcp", "--workspace", opts.workspaceRoot]) : '["mcp"]'}`,
2309
+ // A pin passes --workspace explicitly (highest precedence in cmd_mcp)
2310
+ // IN ADDITION to the env pin below, so a stale cwd / stale cached env
2311
+ // can't drift the child to another repo. JSON.stringify yields a
2312
+ // TOML-valid quoted string array.
2313
+ `args = ${opts.pinTo ? JSON.stringify(["mcp", "--workspace", opts.pinTo]) : '["mcp"]'}`,
2035
2314
  ];
2036
- if (opts.workspaceRoot)
2037
- lines.push(`cwd = ${tomlString(opts.workspaceRoot)}`);
2315
+ if (opts.pinTo)
2316
+ lines.push(`cwd = ${tomlString(opts.pinTo)}`);
2038
2317
  lines.push("tool_timeout_sec = 120", "", "[mcp_servers.vexp.env]");
2039
- if (opts.workspaceRoot)
2040
- lines.push(`VEXP_WORKSPACE = ${tomlString(opts.workspaceRoot)}`);
2318
+ if (opts.pinTo)
2319
+ lines.push(`VEXP_WORKSPACE = ${tomlString(opts.pinTo)}`);
2041
2320
  if (opts.home)
2042
2321
  lines.push(`VEXP_HOME = ${tomlString(opts.home)}`);
2043
2322
  lines.push("");
@@ -2048,7 +2327,7 @@ function buildCodexSection(opts) {
2048
2327
  const desiredUrl = `http://127.0.0.1:${opts.mcpPort}${urlPath}`;
2049
2328
  return `
2050
2329
  [mcp_servers.vexp]
2051
- ${CODEX_MANAGED_MARKER}: http transport (set VEXP_CODEX_TRANSPORT=direct for stdio)
2330
+ ${CODEX_MANAGED_MARKER}: http transport, routed to one project (VEXP_CODEX_TRANSPORT or the vexp.codexMcpTransport setting chooses the transport)
2052
2331
  url = "${desiredUrl}"
2053
2332
  tool_timeout_sec = 120
2054
2333
 
@@ -2058,16 +2337,30 @@ Authorization = "Bearer ${opts.token}"
2058
2337
  }
2059
2338
  /**
2060
2339
  * Configure MCP in ~/.codex/config.toml (global).
2061
- * Codex only reads the global config, not project-level .codex/config.toml.
2340
+ *
2341
+ * Codex reads MCP servers from its user config and, for a project the user
2342
+ * has trusted, from every `.codex/config.toml` between the project root and
2343
+ * the session's folder, deep-merged over the user entry key by key
2344
+ * (codex-rs config loader). vexp writes only the user file: a project file
2345
+ * counts only once the project is trusted, a parse error in it stops Codex
2346
+ * from loading its config at all, it is usually committed with this
2347
+ * machine's paths in it, and Codex's "Always allow" would start writing
2348
+ * into it. Nothing is lost by it: the direct entry names no project (see
2349
+ * buildCodexSection), so one user entry serves every project.
2062
2350
  *
2063
2351
  * Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
2064
2352
  * without VS Code/daemon: `vexp-core mcp` auto-proxies to a running daemon when
2065
2353
  * reachable and otherwise runs an embedded in-process index. Override with the
2066
2354
  * VEXP_CODEX_TRANSPORT env var ("direct" | "http").
2067
2355
  *
2356
+ * `pin` is `vexp use <project> --pin`: the one way to name a project in the entry,
2357
+ * kept by every later setup until `vexp use` changes it. `unpin` is
2358
+ * `vexp use --unpin`. [mcp_servers.vexp.tools.*] tables (Codex's per-tool
2359
+ * approvals) are never touched.
2360
+ *
2068
2361
  * Never clobbers a config the user wrote by hand.
2069
2362
  */
2070
- export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot) {
2363
+ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot, opts = {}) {
2071
2364
  const home = os.homedir();
2072
2365
  if (!home)
2073
2366
  return false;
@@ -2105,13 +2398,14 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
2105
2398
  // Step 2b: adopt the binary a previous install already pinned here, when it
2106
2399
  // still exists. Our own sections are not "user-managed", so `vexp setup` and
2107
2400
  // every IDE activation rewrote this file with their own binary path — and the
2108
- // file is machine-global while Codex is detected by AGENTS.md, so it churned
2109
- // for every project at once. Adopting rather than skipping is what keeps this
2110
- // safe: the binary is interchangeable, but the workspace pin below is not and
2111
- // must still follow the project being configured. See the twin comment in
2401
+ // file is machine-global, so it churned for every project at once. The
2402
+ // binary is interchangeable; adopting it keeps the section byte-identical
2403
+ // whoever writes it. See the twin comment in
2112
2404
  // vexp-vscode/src/providers/agent-auto-config.ts.
2113
2405
  const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
2114
- const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, mcpPort, token, home });
2406
+ // A pin `vexp use --pin` made stays until `vexp use` says otherwise.
2407
+ const pinTo = opts.pin ? workspaceRoot : opts.unpin ? undefined : explicitCodexPin(existingSection);
2408
+ const newSection = buildCodexSection({ transport, coreBinaryPath, workspaceRoot, pinTo, mcpPort, token, home });
2115
2409
  if (!newSection) {
2116
2410
  // direct requested but binary missing → preserve the existing section (only
2117
2411
  // persist any legacy cleanup we already did); never emit a url fallback.
@@ -2423,7 +2717,9 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
2423
2717
  * Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
2424
2718
  * Returns true if config was written/updated.
2425
2719
  */
2426
- export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
2720
+ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot,
2721
+ /** This install's version: an entry left by an OLDER extension is taken over. */
2722
+ version) {
2427
2723
  const home = os.homedir();
2428
2724
  if (!home)
2429
2725
  return false;
@@ -2461,8 +2757,19 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
2461
2757
  // pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
2462
2758
  // this file applies to EVERY project and the pin forces all parallel
2463
2759
  // sessions onto one daemon (the migration described above).
2464
- if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing))
2465
- return false;
2760
+ //
2761
+ // Ordered, not symmetric: an entry inside an OLDER vexp.vexp-vscode-<ver>
2762
+ // folder still resolves right after an update, and keeping it meant
2763
+ // Claude Code lost vexp the moment VS Code reaped that folder (seen on
2764
+ // 3.2.5 machines still pointing at 3.2.4, 2026-09-24). Take over an older
2765
+ // one, keep a newer one, and keep anything we cannot version (the npm
2766
+ // CLI's own path, a custom launcher). Lockstep with the extension.
2767
+ if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing)) {
2768
+ const theirs = vexpInstallVersion(existing);
2769
+ const ours = vexpInstallVersion({ command: desiredCommand, args: desiredArgs }) ?? version;
2770
+ if (!theirs || !ours || !isOlderVexpVersion(theirs, ours))
2771
+ return false;
2772
+ }
2466
2773
  const servers = config.mcpServers ?? {};
2467
2774
  servers["vexp"] = {
2468
2775
  command: desiredCommand,
@@ -2975,7 +3282,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
2975
3282
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2976
3283
  const hookPath = path.join(hookDir, "vexp-hint.sh");
2977
3284
  const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
2978
- const script = vexpHintHookScript(binaryPath);
3285
+ const script = vexpHintHookScript(binaryPath, "claude-code");
2979
3286
  fs.mkdirSync(hookDir, { recursive: true });
2980
3287
  // v5: the edit-time coupling hook, written beside the prompt-time one -
2981
3288
  // but only when interventions are on. It is one of the two mechanisms that
@@ -3097,7 +3404,7 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
3097
3404
  }
3098
3405
  export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir) {
3099
3406
  const pluginPath = path.join(workspaceRoot, pluginDir, "vexp-hint.js");
3100
- const content = vexpOpencodeHintPlugin(binaryPath);
3407
+ const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
3101
3408
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
3102
3409
  const existed = fs.existsSync(pluginPath);
3103
3410
  if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
@@ -3113,25 +3420,26 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
3113
3420
  * Fail-open: missing bash/binary/daemon => no output => vanilla.
3114
3421
  */
3115
3422
  export function installCodexHintHook(workspaceRoot, binaryPath) {
3116
- const dir = path.join(workspaceRoot, ".codex");
3423
+ // Codex has no project-dir variable, so the command names the script by
3424
+ // its absolute path: spelled with the drive letter the extension uses
3425
+ // too, or each installer rewrites what the other wrote (upperDriveLetter).
3426
+ const dir = path.join(upperDriveLetter(workspaceRoot), ".codex");
3117
3427
  const hookPath = path.join(dir, "vexp-hint.sh");
3118
3428
  const hooksJsonPath = path.join(dir, "hooks.json");
3119
- const script = vexpHintHookScript(binaryPath);
3429
+ const script = vexpHintHookScript(binaryPath, "codex");
3120
3430
  fs.mkdirSync(dir, { recursive: true });
3121
3431
  const existed = fs.existsSync(hookPath);
3122
3432
  let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
3123
3433
  if (!scriptIdentical) {
3124
3434
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3125
3435
  }
3126
- // Windows gets a batch twin. Codex runs a hook command through
3127
- // `cmd.exe /C` (COMSPEC, codex-rs/hooks command_runner), so `bash "..."`
3128
- // reaches a shell that has never heard of bash: a user with neither Git
3129
- // Bash nor WSL got "bash not found" on every prompt and orientation was
3130
- // inert. Codex's own answer is `commandWindows`, an optional per-OS
3131
- // override on the command handler (openai/codex#22159, ~0.131, months
3132
- // before the version that requires the nested `hooks` shape we already
3133
- // write). Older builds ignore the field rather than reject the file —
3134
- // the handler is not deny_unknown_fields, unlike the top level.
3436
+ // Windows gets a batch twin. `bash "..."` needs a bash, and a user with
3437
+ // neither Git Bash nor WSL got "bash not found" on every prompt, so
3438
+ // orientation was inert. Codex's own answer is `commandWindows`, an
3439
+ // optional per-OS override on the command handler (openai/codex#22159,
3440
+ // ~0.131, months before the version that requires the nested `hooks` shape
3441
+ // we already write). Older builds ignore the field rather than reject the
3442
+ // file — the handler is not deny_unknown_fields, unlike the top level.
3135
3443
  const cmdPath = path.join(dir, "vexp-hint.cmd");
3136
3444
  let cmdCommand;
3137
3445
  if (process.platform === "win32") {
@@ -3141,10 +3449,10 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3141
3449
  fs.writeFileSync(cmdPath, cmdScript);
3142
3450
  scriptIdentical = false;
3143
3451
  }
3144
- // Quoted: Codex hands `cmd /C` the whole line wrapped in one more pair
3145
- // of quotes, and cmd then strips the outermost pair — which leaves this
3146
- // path quoted and therefore safe to contain spaces.
3147
- cmdCommand = `"${cmdPath}"`;
3452
+ // Through `cmd /d /c call`, not the bare path: Codex runs the line in
3453
+ // PowerShell by default, where a quoted path is only a string to print
3454
+ // (codexWindowsHookCommand has the details).
3455
+ cmdCommand = codexWindowsHookCommand(cmdPath);
3148
3456
  }
3149
3457
  let root = {};
3150
3458
  if (fs.existsSync(hooksJsonPath)) {
@@ -3180,9 +3488,8 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3180
3488
  ? codexHooks.UserPromptSubmit
3181
3489
  : [];
3182
3490
  const existing = [...wrapped, ...legacyTopLevel];
3183
- const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
3184
3491
  // Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
3185
- filtered.push({
3492
+ const ours = {
3186
3493
  hooks: [
3187
3494
  {
3188
3495
  type: "command",
@@ -3191,11 +3498,16 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3191
3498
  timeout: 5,
3192
3499
  },
3193
3500
  ],
3194
- });
3501
+ };
3502
+ const merged = placeCodexHintEntry(existing, ours);
3195
3503
  const before = JSON.stringify(root);
3196
- codexHooks.UserPromptSubmit = filtered;
3504
+ codexHooks.UserPromptSubmit = merged;
3197
3505
  root.hooks = codexHooks;
3198
3506
  const identical = before === JSON.stringify(root);
3507
+ if (codexHookDefinitionChanged(wrapped, merged, ours)) {
3508
+ if (!codexHookApprovals.includes(workspaceRoot))
3509
+ codexHookApprovals.push(workspaceRoot);
3510
+ }
3199
3511
  if (scriptIdentical && identical)
3200
3512
  return null;
3201
3513
  if (!identical) {
@@ -3203,6 +3515,36 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3203
3515
  }
3204
3516
  return existed ? "updated" : "created";
3205
3517
  }
3518
+ /**
3519
+ * vexp's UserPromptSubmit group, put where the previous one was.
3520
+ *
3521
+ * Codex keys a hook's trust record by its position in the event's list
3522
+ * (`<hooks.json>:user_prompt_submit:<group>:<handler>`, codex-rs hooks
3523
+ * hook_key). Removing our group and appending it at the end moved it whenever
3524
+ * the user had a hook of their own after it: our key changed, and theirs
3525
+ * shifted onto hashes recorded for other hooks, so every one of them read as
3526
+ * modified and stopped running until approved again. In place, nothing moves.
3527
+ * Twin: agent-auto-config.ts placeCodexHintEntry.
3528
+ */
3529
+ export function placeCodexHintEntry(existing, ours) {
3530
+ const at = existing.findIndex((h) => isVexpHintHookEntry(h));
3531
+ const merged = existing.flatMap((h, i) => (i === at ? [ours] : isVexpHintHookEntry(h) ? [] : [h]));
3532
+ if (at < 0)
3533
+ merged.push(ours);
3534
+ return merged;
3535
+ }
3536
+ /**
3537
+ * Did writing `ours` change what Codex trusts: a first write, a different
3538
+ * definition, or a different position? `before` is the list Codex read until
3539
+ * now (the nested one; a legacy top-level list is not loaded by a Codex that
3540
+ * reads hooks.json this way). Twin: agent-auto-config.ts.
3541
+ */
3542
+ export function codexHookDefinitionChanged(before, after, ours) {
3543
+ const was = before.findIndex((h) => isVexpHintHookEntry(h));
3544
+ if (was < 0)
3545
+ return true;
3546
+ return was !== after.indexOf(ours) || JSON.stringify(before[was]) !== JSON.stringify(ours);
3547
+ }
3206
3548
  // ---------------------------------------------------------------------------
3207
3549
  // opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
3208
3550
  // is healthy. opencode has no PreToolUse hook, but auto-loads plugins from