vexp-cli 3.2.4 → 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.
@@ -11,7 +11,9 @@ import * as path from "path";
11
11
  import * as os from "os";
12
12
  import { spawnSync } from "child_process";
13
13
  import * as crypto from "crypto";
14
- import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook } from "./hook-template.js";
14
+ import { canonicalWorkspaceRoot } from "./socket-path.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";
15
17
  // ---------------------------------------------------------------------------
16
18
  // Constants
17
19
  // ---------------------------------------------------------------------------
@@ -39,7 +41,9 @@ const CANONICAL_VEXP_TOML_SECTION_RE = /\n?\[mcp_servers\.vexp(?:\.[a-z_]+)?\][\
39
41
  * MUST stay in lockstep with that function.
40
42
  */
41
43
  function workspaceHash(workspaceRoot) {
42
- const input = workspaceRoot.toLowerCase();
44
+ // The route hash must name the row the daemon registers: its canonical
45
+ // root, which on a mapped drive is the share, not the drive letter.
46
+ const input = canonicalWorkspaceRoot(workspaceRoot).toLowerCase();
43
47
  let hash = BigInt("0xcbf29ce484222325");
44
48
  const prime = BigInt("0x100000001b3");
45
49
  const mask = BigInt("0xffffffffffffffff");
@@ -113,10 +117,29 @@ export function interventionMode() {
113
117
  // ---------------------------------------------------------------------------
114
118
  // Agent detectors - mirrors VS Code extension's AGENT_DETECTORS
115
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
+ }
116
133
  const AGENT_DETECTORS = [
117
134
  {
118
135
  agent: "Claude Code",
119
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",
120
143
  configFile: ".claude/CLAUDE.md",
121
144
  templateName: "claude-code",
122
145
  // MCP configured in ~/.claude.json (user-scope) via configureClaudeCodeGlobal()
@@ -182,7 +205,19 @@ const AGENT_DETECTORS = [
182
205
  },
183
206
  {
184
207
  agent: "Codex",
185
- 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,
186
221
  configFile: "AGENTS.md",
187
222
  templateName: "agents-md",
188
223
  // MCP configured in ~/.codex/config.toml (global) via configureCodexGlobal()
@@ -263,13 +298,152 @@ const AGENT_DETECTORS = [
263
298
  // same mandate every AGENTS.md agent gets, under a name of its own. It
264
299
  // connects to vexp on its side, so there is no MCP file to write here:
265
300
  // the instructions are the whole integration. Detected by the file
266
- // itself, as Codex is by AGENTS.md.
301
+ // itself (Codex, by contrast, by its own ~/.codex footprint).
267
302
  agent: "Friday Code",
268
303
  detectPath: "friday.md",
269
304
  configFile: "friday.md",
270
305
  templateName: "agents-md",
271
306
  },
272
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
+ }
273
447
  // ---------------------------------------------------------------------------
274
448
  // Public API
275
449
  // ---------------------------------------------------------------------------
@@ -306,8 +480,9 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
306
480
  }
307
481
  break;
308
482
  case "Codex":
309
- out.push("~/.codex/config.toml or .codex/config.toml (MCP server entry)");
310
- 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)`);
311
486
  break;
312
487
  case "Cline":
313
488
  out.push("cline_mcp_settings.json in each installed VS Code variant (MCP, machine-global)");
@@ -353,37 +528,29 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
353
528
  return out;
354
529
  }
355
530
  /**
356
- * 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).
357
533
  */
358
534
  export function detectAgents(workspaceRoot) {
359
- return AGENT_DETECTORS.filter((d) => {
360
- const markers = [d.detectPath, ...(d.detectPaths ?? [])];
361
- if (markers.some((m) => fs.existsSync(path.join(workspaceRoot, m)))) {
362
- return true;
363
- }
364
- // Home-level marker: the IDE is installed on this machine even though
365
- // the workspace carries no footprint (e.g. Antigravity).
366
- if (d.detectHome && fs.existsSync(path.join(os.homedir(), d.detectHome))) {
367
- return true;
368
- }
369
- if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p))) {
370
- return true;
371
- }
372
- return false;
373
- });
535
+ return AGENT_DETECTORS.filter((d) => classifyAgent(d, workspaceRoot) !== undefined);
374
536
  }
375
537
  /**
376
- * Configure all detected agents: write instruction files + MCP configs.
377
- * 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.
378
541
  */
379
542
  export function configureAgents(workspaceRoot, binaryPath, version, agentFilter, mcpServerPath) {
380
543
  const results = [];
381
544
  const mcpConfigs = [];
382
545
  const writtenConfigFiles = new Set();
383
- let agents = detectAgents(workspaceRoot);
546
+ let agents;
384
547
  if (agentFilter && agentFilter.length > 0) {
385
548
  const filter = agentFilter.map((a) => a.toLowerCase());
386
- 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));
387
554
  }
388
555
  for (const detected of agents) {
389
556
  const detector = withCursorTarget(detected, workspaceRoot);
@@ -470,7 +637,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
470
637
  }
471
638
  // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
472
639
  if (detector.agent === "Claude Code") {
473
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
640
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
474
641
  if (wrote)
475
642
  mcpConfigs.push("~/.claude.json");
476
643
  // Event-driven hint hook: default-on (non-blocking, fail-open — the
@@ -847,7 +1014,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
847
1014
  removeDeadWindsurfMcpJson(workspaceRoot);
848
1015
  }
849
1016
  if (detector.agent === "Claude Code") {
850
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot);
1017
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
851
1018
  if (wrote)
852
1019
  mcpConfigs.push("~/.claude.json");
853
1020
  installClaudeCodeHintHook(workspaceRoot, binaryPath);
@@ -1223,6 +1390,50 @@ export function isBareInterpreter(entry) {
1223
1390
  function adoptableEntry(entry) {
1224
1391
  return vexpEntryStillResolves(entry) && !isBareInterpreter(entry) && !isEditorExecutable(entry);
1225
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
+ }
1226
1437
  function vexpEntryStillResolves(entry) {
1227
1438
  if (!entry || typeof entry !== "object")
1228
1439
  return false;
@@ -1350,6 +1561,38 @@ const unreachableTargets = [];
1350
1561
  export function takeUnreachableTargets() {
1351
1562
  return unreachableTargets.splice(0, unreachableTargets.length);
1352
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
+ }
1353
1596
  /**
1354
1597
  * Cline keeps its MCP registry inside the VS Code extension's globalStorage,
1355
1598
  * so there is nothing to write until the extension has been installed once.
@@ -1942,6 +2185,10 @@ function readMcpPort(workspaceRoot) {
1942
2185
  // tell OUR config apart from a config the user hand-wrote. Must stay in sync
1943
2186
  // with packages/vexp-vscode/src/providers/agent-auto-config.ts.
1944
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'";
1945
2192
  /** Quote a value as a TOML literal string (single quotes, no escaping) so
1946
2193
  * Windows paths work verbatim. Falls back to a basic escaped string if the
1947
2194
  * value contains a single quote. */
@@ -1979,6 +2226,13 @@ function codexDirectBinary(section) {
1979
2226
  return undefined;
1980
2227
  return cmd;
1981
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
+ }
1982
2236
  function extractCodexVexpSection(content) {
1983
2237
  const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
1984
2238
  return m ? m.join("\n") : "";
@@ -1999,6 +2253,19 @@ export function codexGlobalSectionIsUserManaged() {
1999
2253
  }
2000
2254
  return isUserManagedCodexSection(extractCodexVexpSection(content));
2001
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
+ }
2002
2269
  /** True when the existing [mcp_servers.vexp] section was written by the user
2003
2270
  * (not by vexp) and must NOT be overwritten. */
2004
2271
  function isUserManagedCodexSection(section) {
@@ -2015,7 +2282,19 @@ function isUserManagedCodexSection(section) {
2015
2282
  return false;
2016
2283
  }
2017
2284
  /** Build the canonical [mcp_servers.vexp] section for the chosen transport.
2018
- * 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. */
2019
2298
  function buildCodexSection(opts) {
2020
2299
  if (opts.transport === "direct") {
2021
2300
  if (!opts.coreBinaryPath)
@@ -2023,18 +2302,21 @@ function buildCodexSection(opts) {
2023
2302
  const lines = [
2024
2303
  "",
2025
2304
  "[mcp_servers.vexp]",
2026
- `${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)`,
2027
2308
  `command = ${tomlString(opts.coreBinaryPath)}`,
2028
- // Pass --workspace explicitly (highest precedence in cmd_mcp) IN ADDITION to
2029
- // the env pin below, so a stale cwd / stale cached env can't drift the child
2030
- // to the wrong repo. JSON.stringify yields a TOML-valid quoted string array.
2031
- `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"]'}`,
2032
2314
  ];
2033
- if (opts.workspaceRoot)
2034
- lines.push(`cwd = ${tomlString(opts.workspaceRoot)}`);
2315
+ if (opts.pinTo)
2316
+ lines.push(`cwd = ${tomlString(opts.pinTo)}`);
2035
2317
  lines.push("tool_timeout_sec = 120", "", "[mcp_servers.vexp.env]");
2036
- if (opts.workspaceRoot)
2037
- lines.push(`VEXP_WORKSPACE = ${tomlString(opts.workspaceRoot)}`);
2318
+ if (opts.pinTo)
2319
+ lines.push(`VEXP_WORKSPACE = ${tomlString(opts.pinTo)}`);
2038
2320
  if (opts.home)
2039
2321
  lines.push(`VEXP_HOME = ${tomlString(opts.home)}`);
2040
2322
  lines.push("");
@@ -2045,7 +2327,7 @@ function buildCodexSection(opts) {
2045
2327
  const desiredUrl = `http://127.0.0.1:${opts.mcpPort}${urlPath}`;
2046
2328
  return `
2047
2329
  [mcp_servers.vexp]
2048
- ${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)
2049
2331
  url = "${desiredUrl}"
2050
2332
  tool_timeout_sec = 120
2051
2333
 
@@ -2055,16 +2337,30 @@ Authorization = "Bearer ${opts.token}"
2055
2337
  }
2056
2338
  /**
2057
2339
  * Configure MCP in ~/.codex/config.toml (global).
2058
- * 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.
2059
2350
  *
2060
2351
  * Default transport is `direct` (stdio: `vexp-core mcp`), which works with AND
2061
2352
  * without VS Code/daemon: `vexp-core mcp` auto-proxies to a running daemon when
2062
2353
  * reachable and otherwise runs an embedded in-process index. Override with the
2063
2354
  * VEXP_CODEX_TRANSPORT env var ("direct" | "http").
2064
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
+ *
2065
2361
  * Never clobbers a config the user wrote by hand.
2066
2362
  */
2067
- export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot) {
2363
+ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot, opts = {}) {
2068
2364
  const home = os.homedir();
2069
2365
  if (!home)
2070
2366
  return false;
@@ -2102,13 +2398,14 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot)
2102
2398
  // Step 2b: adopt the binary a previous install already pinned here, when it
2103
2399
  // still exists. Our own sections are not "user-managed", so `vexp setup` and
2104
2400
  // every IDE activation rewrote this file with their own binary path — and the
2105
- // file is machine-global while Codex is detected by AGENTS.md, so it churned
2106
- // for every project at once. Adopting rather than skipping is what keeps this
2107
- // safe: the binary is interchangeable, but the workspace pin below is not and
2108
- // 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
2109
2404
  // vexp-vscode/src/providers/agent-auto-config.ts.
2110
2405
  const coreBinaryPath = (transport === "direct" ? codexDirectBinary(existingSection) : undefined) ?? ourCoreBinary;
2111
- 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 });
2112
2409
  if (!newSection) {
2113
2410
  // direct requested but binary missing → preserve the existing section (only
2114
2411
  // persist any legacy cleanup we already did); never emit a url fallback.
@@ -2420,7 +2717,9 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
2420
2717
  * Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
2421
2718
  * Returns true if config was written/updated.
2422
2719
  */
2423
- 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) {
2424
2723
  const home = os.homedir();
2425
2724
  if (!home)
2426
2725
  return false;
@@ -2458,8 +2757,19 @@ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRo
2458
2757
  // pinning VEXP_WORKSPACE must be rewritten no matter whose it is, because
2459
2758
  // this file applies to EVERY project and the pin forces all parallel
2460
2759
  // sessions onto one daemon (the migration described above).
2461
- if (removed.length === 0 && prevEnv === undefined && vexpEntryStillResolves(existing))
2462
- 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
+ }
2463
2773
  const servers = config.mcpServers ?? {};
2464
2774
  servers["vexp"] = {
2465
2775
  command: desiredCommand,
@@ -2972,7 +3282,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
2972
3282
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2973
3283
  const hookPath = path.join(hookDir, "vexp-hint.sh");
2974
3284
  const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
2975
- const script = vexpHintHookScript(binaryPath);
3285
+ const script = vexpHintHookScript(binaryPath, "claude-code");
2976
3286
  fs.mkdirSync(hookDir, { recursive: true });
2977
3287
  // v5: the edit-time coupling hook, written beside the prompt-time one -
2978
3288
  // but only when interventions are on. It is one of the two mechanisms that
@@ -3094,7 +3404,7 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
3094
3404
  }
3095
3405
  export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir) {
3096
3406
  const pluginPath = path.join(workspaceRoot, pluginDir, "vexp-hint.js");
3097
- const content = vexpOpencodeHintPlugin(binaryPath);
3407
+ const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
3098
3408
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
3099
3409
  const existed = fs.existsSync(pluginPath);
3100
3410
  if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
@@ -3110,25 +3420,26 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
3110
3420
  * Fail-open: missing bash/binary/daemon => no output => vanilla.
3111
3421
  */
3112
3422
  export function installCodexHintHook(workspaceRoot, binaryPath) {
3113
- 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");
3114
3427
  const hookPath = path.join(dir, "vexp-hint.sh");
3115
3428
  const hooksJsonPath = path.join(dir, "hooks.json");
3116
- const script = vexpHintHookScript(binaryPath);
3429
+ const script = vexpHintHookScript(binaryPath, "codex");
3117
3430
  fs.mkdirSync(dir, { recursive: true });
3118
3431
  const existed = fs.existsSync(hookPath);
3119
3432
  let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
3120
3433
  if (!scriptIdentical) {
3121
3434
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3122
3435
  }
3123
- // Windows gets a batch twin. Codex runs a hook command through
3124
- // `cmd.exe /C` (COMSPEC, codex-rs/hooks command_runner), so `bash "..."`
3125
- // reaches a shell that has never heard of bash: a user with neither Git
3126
- // Bash nor WSL got "bash not found" on every prompt and orientation was
3127
- // inert. Codex's own answer is `commandWindows`, an optional per-OS
3128
- // override on the command handler (openai/codex#22159, ~0.131, months
3129
- // before the version that requires the nested `hooks` shape we already
3130
- // write). Older builds ignore the field rather than reject the file —
3131
- // 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.
3132
3443
  const cmdPath = path.join(dir, "vexp-hint.cmd");
3133
3444
  let cmdCommand;
3134
3445
  if (process.platform === "win32") {
@@ -3138,10 +3449,10 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3138
3449
  fs.writeFileSync(cmdPath, cmdScript);
3139
3450
  scriptIdentical = false;
3140
3451
  }
3141
- // Quoted: Codex hands `cmd /C` the whole line wrapped in one more pair
3142
- // of quotes, and cmd then strips the outermost pair — which leaves this
3143
- // path quoted and therefore safe to contain spaces.
3144
- 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);
3145
3456
  }
3146
3457
  let root = {};
3147
3458
  if (fs.existsSync(hooksJsonPath)) {
@@ -3177,9 +3488,8 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3177
3488
  ? codexHooks.UserPromptSubmit
3178
3489
  : [];
3179
3490
  const existing = [...wrapped, ...legacyTopLevel];
3180
- const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
3181
3491
  // Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
3182
- filtered.push({
3492
+ const ours = {
3183
3493
  hooks: [
3184
3494
  {
3185
3495
  type: "command",
@@ -3188,11 +3498,16 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3188
3498
  timeout: 5,
3189
3499
  },
3190
3500
  ],
3191
- });
3501
+ };
3502
+ const merged = placeCodexHintEntry(existing, ours);
3192
3503
  const before = JSON.stringify(root);
3193
- codexHooks.UserPromptSubmit = filtered;
3504
+ codexHooks.UserPromptSubmit = merged;
3194
3505
  root.hooks = codexHooks;
3195
3506
  const identical = before === JSON.stringify(root);
3507
+ if (codexHookDefinitionChanged(wrapped, merged, ours)) {
3508
+ if (!codexHookApprovals.includes(workspaceRoot))
3509
+ codexHookApprovals.push(workspaceRoot);
3510
+ }
3196
3511
  if (scriptIdentical && identical)
3197
3512
  return null;
3198
3513
  if (!identical) {
@@ -3200,6 +3515,36 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3200
3515
  }
3201
3516
  return existed ? "updated" : "created";
3202
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
+ }
3203
3548
  // ---------------------------------------------------------------------------
3204
3549
  // opencode plugin - blocks grep/glob (and shelled-out search) when the daemon
3205
3550
  // is healthy. opencode has no PreToolUse hook, but auto-loads plugins from