vexp-cli 3.3.9 → 3.4.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.
@@ -2,9 +2,10 @@
2
2
  * Agent auto-detection and configuration for the vexp CLI.
3
3
  *
4
4
  * Ported from packages/vexp-vscode/src/providers/agent-auto-config.ts
5
- * with VS Code dependencies removed. Uses the Rust binary path
6
- * (`~/.vexp/bin/vexp-core mcp`) as the MCP command instead of
7
- * `node <mcp-server.cjs>`.
5
+ * with VS Code dependencies removed. The MCP command is the npm package's
6
+ * own binary (`<npm>/@vexp/core-<platform>/bin/vexp-core mcp`, the
7
+ * version-free path getBinaryPath returns) or `node <mcp-server.cjs>` when
8
+ * the bundle ships; nothing creates a `~/.vexp/bin` launcher.
8
9
  */
9
10
  import * as fs from "fs";
10
11
  import * as path from "path";
@@ -12,8 +13,10 @@ import * as os from "os";
12
13
  import { spawnSync } from "child_process";
13
14
  import * as crypto from "crypto";
14
15
  import { canonicalWorkspaceRoot } from "./socket-path.js";
15
- import { readAgentConsent, consentKey, decisionForKey } from "./agent-consent.js";
16
+ import { readAgentConsent, decisionsFor } from "./agent-consent.js";
17
+ import { linkedWorktreeMain, sameRepositoryCheckout } from "./git-worktree.js";
16
18
  import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSubagentHookScript, vexpSearchHookScript, vexpHintHookCmdScript, codexWindowsHookCommand, opencodeFamilyAgent, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin, vexpOpencodeCompressPlugin, bakeEditHintHook, bakeReadHintHook, bakeBashCapHook, upperDriveLetter } from "./hook-template.js";
19
+ import { committedHookScriptLine, committedHookScriptStays, readHookScript } from "./hook-scripts.js";
17
20
  // ---------------------------------------------------------------------------
18
21
  // Constants
19
22
  // ---------------------------------------------------------------------------
@@ -149,6 +152,36 @@ function windsurfOnThisMachine() {
149
152
  }
150
153
  });
151
154
  }
155
+ /**
156
+ * Does this machine show Antigravity? What Antigravity itself leaves under
157
+ * ~/.gemini (measured with agy 1.5.3, 2026-11-07): anything in
158
+ * ~/.gemini/antigravity beside mcp_config.json and its backup (vexp up to
159
+ * 3.3.9 created that folder and that file itself, on machines that never
160
+ * had Antigravity, so they prove nothing); the agy CLI's app data,
161
+ * ~/.gemini/antigravity-cli; the marker every Antigravity surface writes at
162
+ * its first start, ~/.gemini/config/.migrated; or the agy binary the VS Code
163
+ * extension downloads to ~/.gemini/bin. The same test as
164
+ * windsurfOnThisMachine: vexp's own leftover folder made every project on
165
+ * the machine offer Antigravity, and GEMINI.md set it up unasked. Lockstep
166
+ * with the extension, which can also ask the editor it runs in; a terminal
167
+ * has no editor to ask, and `--agents Antigravity` is there for that case.
168
+ */
169
+ export function antigravityOnThisMachine() {
170
+ const gemini = path.join(os.homedir(), ".gemini");
171
+ try {
172
+ if (fs.readdirSync(path.join(gemini, "antigravity")).some((e) => e !== "mcp_config.json" && e !== "mcp_config.json.vexp-bak")) {
173
+ return true;
174
+ }
175
+ }
176
+ catch {
177
+ /* no such folder */
178
+ }
179
+ if (isDirectory(path.join(gemini, "antigravity-cli")))
180
+ return true;
181
+ if (fs.existsSync(path.join(gemini, "config", ".migrated")))
182
+ return true;
183
+ return ["agy", "agy.exe"].some((bin) => fs.existsSync(path.join(gemini, "bin", bin)));
184
+ }
152
185
  const AGENT_DETECTORS = [
153
186
  {
154
187
  agent: "Claude Code",
@@ -311,14 +344,26 @@ const AGENT_DETECTORS = [
311
344
  agent: "Antigravity",
312
345
  // Antigravity keeps NO workspace marker dir of its own: it reads the
313
346
  // cross-tool AGENTS.md (v1.20.3+), GEMINI.md, and .agent/rules/ from
314
- // the workspace, and stores its global state under ~/.gemini/antigravity.
347
+ // the workspace, and keeps its app data under ~/.gemini/antigravity.
348
+ // `.agents` is no marker: ZCode's .agents/mcp.json, Agent Skills and
349
+ // vexp's own .agents/mcp_config.json live there too.
350
+ //
351
+ // GEMINI.md and .agent are committed for the teammates who use
352
+ // Antigravity, so, as for Windsurf, they set it up without a question
353
+ // only where the machine shows Antigravity (antigravityOnThisMachine);
354
+ // elsewhere they are a hint and the user is asked. The machine-only
355
+ // marker is the same evidence, not the bare ~/.gemini/antigravity
356
+ // folder, which vexp itself created up to 3.3.9. Lockstep with the
357
+ // extension.
315
358
  detectPath: "GEMINI.md",
316
359
  detectPaths: [".agent"],
317
- detectHome: ".gemini/antigravity",
360
+ onThisMachine: antigravityOnThisMachine,
361
+ detectMachine: antigravityOnThisMachine,
318
362
  configFile: "AGENTS.md",
319
363
  templateName: "agents-md",
320
- // MCP is machine-global (~/.gemini/antigravity/mcp_config.json) — handled
321
- // by configureAntigravityGlobal(), not via mcpConfigFile.
364
+ // MCP: the project file <root>/.agents/mcp_config.json, pinned to this
365
+ // project - written by configureAntigravityProject(), not via
366
+ // mcpConfigFile (the entry is the binary, never node).
322
367
  },
323
368
  {
324
369
  // ZCode (zcode.z.ai), Z.ai's desktop agentic IDE. It reads the
@@ -445,6 +490,8 @@ export function classifyAgent(d, workspaceRoot) {
445
490
  return "project";
446
491
  if (d.detectHome && isDirectory(path.join(os.homedir(), d.detectHome)))
447
492
  return "machine";
493
+ if (d.detectMachine && d.detectMachine())
494
+ return "machine";
448
495
  if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p)))
449
496
  return "machine";
450
497
  if (projectHint(d, workspaceRoot))
@@ -464,7 +511,7 @@ export function classifyAgents(workspaceRoot) {
464
511
  /**
465
512
  * What a setup does without asking. Project markers and earlier "yes"
466
513
  * answers are configured as they always were; traces on the machine alone
467
- * (Codex's login, Antigravity's folder, Cline's storage, ~/.claude) and a
514
+ * (Codex's login, Antigravity's own files, Cline's storage, ~/.claude) and a
468
515
  * hint in the project (a bare `.github`, a `.windsurf` folder) wait for the
469
516
  * user. Nothing vexp wrote counts as an answer: a 3.2.5 user whose Codex
470
517
  * setup came from AGENTS.md is asked once, like everyone else, and so is a
@@ -472,7 +519,13 @@ export function classifyAgents(workspaceRoot) {
472
519
  */
473
520
  export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
474
521
  const plan = { configure: [], offer: [], declined: [], kinds: {} };
475
- const key = consentKey(workspaceRoot);
522
+ // A linked git worktree with no answer of its own for an agent takes the
523
+ // main checkout's (decisionsFor): it is the same project, and every new
524
+ // worktree asked every question again (field report, 2026-11-07). The
525
+ // writers that then run there leave the files committed to the
526
+ // repository as they are where only this checkout's path would change
527
+ // (committedPin, committedCleanupBlocked). Lockstep with the extension.
528
+ const decisionOf = decisionsFor(consent, workspaceRoot);
476
529
  // The extension asks one question after another, and one left open keeps
477
530
  // the rest from being seen: a tool the machine shows goes before one the
478
531
  // project only hints at. The same order here, so the two plans compare.
@@ -481,7 +534,7 @@ export function planAgentSetup(workspaceRoot, consent = readAgentConsent()) {
481
534
  const kind = classifyAgent(d, workspaceRoot);
482
535
  if (kind)
483
536
  plan.kinds[d.agent] = kind;
484
- const decision = decisionForKey(consent, key, d.agent);
537
+ const decision = decisionOf(d.agent);
485
538
  if (kind === "project" || decision === "yes")
486
539
  plan.configure.push(d.agent);
487
540
  else if (decision === "no" || decision === "never") {
@@ -592,7 +645,11 @@ export function plannedWrites(agent, guard = guardMode(), interventions = interv
592
645
  out.push(".kilo/plugins/vexp-guard.js (guard, opt-in)");
593
646
  break;
594
647
  case "Antigravity":
595
- out.push("~/.gemini/antigravity MCP config (user scope)");
648
+ out.push(".agents/mcp_config.json (MCP server entry, pinned to this project; kept out of git in .git/info/exclude)");
649
+ // Setup also takes vexp's old unpinned entry out of a machine-wide file
650
+ // (removeDeadAntigravityEntries): named only where there is one.
651
+ for (const file of deadAntigravityEntryFiles())
652
+ out.push(`${file} (vexp's old unpinned entry removed)`);
596
653
  break;
597
654
  case "Windsurf":
598
655
  out.push("~/.codeium/windsurf/mcp_config.json (MCP server entry, user scope — Cascade)");
@@ -646,311 +703,338 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
646
703
  }
647
704
  for (const detected of agents) {
648
705
  const detector = withCursorTarget(detected, workspaceRoot);
649
- const configFilePath = path.join(workspaceRoot, detector.configFile);
650
- const alreadyExists = fs.existsSync(configFilePath);
651
- // Sweep the location we used to write to. For several releases the
652
- // Windsurf rules went to `.windsurf/rules.md`, which Cascade does not
653
- // read: its rules are `.windsurf/rules/*.md`, `.devin/rules/*.md`, or the
654
- // legacy root `.windsurfrules`. Upgrading users would otherwise keep a
655
- // stale, unread copy of our instructions beside the live one.
656
- if (detector.agent === "Windsurf") {
657
- removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"));
658
- }
659
- const content = generateAgentConfig(detector.templateName, {
660
- workspaceRoot,
661
- binaryPath,
662
- configFile: detector.configFile,
663
- version,
664
- });
665
- // Write instruction file (deduplicated by configFile path)
666
- let action = "skipped";
667
- if (!writtenConfigFiles.has(detector.configFile)) {
668
- if (detector.templateName === "continue") {
669
- const continuePayload = {
670
- customCommands: [
671
- {
672
- name: "vexp-context",
673
- description: "Get vexp context for current task",
674
- prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
675
- },
676
- ],
677
- contextProviders: [
678
- {
679
- name: "vexp",
680
- params: {},
681
- },
682
- ],
683
- mcpServers: [
684
- {
685
- name: "vexp",
686
- command: binaryPath,
687
- args: ["mcp"],
688
- type: "stdio",
689
- },
690
- ],
691
- };
692
- const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
693
- action = mergeResult === "merged" ? "updated" : "skipped";
694
- }
695
- else {
696
- action = appendOrCreate(configFilePath, content, version);
697
- }
698
- writtenConfigFiles.add(detector.configFile);
706
+ // A folder of the agent's that is a link to nothing (danglingAgentFolder):
707
+ // mkdirSync and writeFileSync both throw ENOENT there, and the exception
708
+ // left this loop, so the setup failed for every agent, not only for the
709
+ // one whose folder it was (bug at 3.3.9). That agent alone is skipped,
710
+ // and the end of setup says why (takeUnreachableTargets).
711
+ const dangling = danglingAgentFolder(detector, workspaceRoot);
712
+ if (dangling) {
713
+ noteDanglingAgentFolder(dangling);
714
+ continue;
699
715
  }
700
- // Write MCP config for agents with mcpConfigFile (project-level JSON)
701
- if (detector.mcpConfigFile) {
702
- const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
703
- const isKiro = detector.agent === "Kiro";
704
- // Windsurf and Kiro auto-approve vexp's tools so the agent isn't prompted
705
- // on every run_pipeline call. Kiro's field is `autoApprove`; others use
706
- // `alwaysAllow`.
707
- const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
708
- const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
709
- // Kiro resolves the MCP `command` via PATH, and a macOS GUI launch has no
710
- // `node` on PATH — so the `node <mcp-server.cjs>` transport silently fails
711
- // to start and run_pipeline never appears (the agent then falls back to
712
- // grep/sed). Point Kiro straight at the vexp binary (`vexp-core mcp`, which
713
- // reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
714
- // needed, an absolute path that always resolves.
715
- const mcpSrv = isKiro ? undefined : mcpServerPath;
716
- if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
717
- mcpConfigs.push(detector.mcpConfigFile);
716
+ // Anything else that throws for one agent stops that agent only, and
717
+ // the end of setup says so (noteAgentSetupStopped): the agents after
718
+ // it are set up all the same.
719
+ try {
720
+ const configFilePath = path.join(workspaceRoot, detector.configFile);
721
+ const alreadyExists = fs.existsSync(configFilePath);
722
+ // Sweep the location we used to write to. For several releases the
723
+ // Windsurf rules went to `.windsurf/rules.md`, which Cascade does not
724
+ // read: its rules are `.windsurf/rules/*.md`, `.devin/rules/*.md`, or the
725
+ // legacy root `.windsurfrules`. Upgrading users would otherwise keep a
726
+ // stale, unread copy of our instructions beside the live one.
727
+ if (detector.agent === "Windsurf") {
728
+ removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"), "Windsurf");
718
729
  }
719
- }
720
- // Windsurf / Devin Desktop: the two files the agent actually reads.
721
- if (detector.agent === "Windsurf") {
722
- if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
723
- mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
724
- if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
725
- mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
726
- if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
727
- mcpConfigs.push(".devin/mcp_config.json");
728
- removeDeadWindsurfMcpJson(workspaceRoot);
729
- }
730
- // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
731
- if (detector.agent === "Claude Code") {
732
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
733
- if (wrote)
734
- mcpConfigs.push("~/.claude.json");
735
- // Event-driven hint hook: default-on (non-blocking, fail-open — the
736
- // 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
737
- const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
738
- installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
739
- installClaudeCodeSearchHook(workspaceRoot, binaryPath);
740
- // Both doors. Bounding either alone measured as no change at all: the
741
- // work simply moves to the other one.
742
- if (interventionMode() === "on") {
743
- installClaudeCodeReadHint(workspaceRoot, binaryPath);
744
- installClaudeCodeBashCap(workspaceRoot, binaryPath);
730
+ const content = generateAgentConfig(detector.templateName, {
731
+ workspaceRoot,
732
+ binaryPath,
733
+ configFile: detector.configFile,
734
+ version,
735
+ });
736
+ // Write instruction file (deduplicated by configFile path)
737
+ let action = "skipped";
738
+ if (!writtenConfigFiles.has(detector.configFile)) {
739
+ if (detector.templateName === "continue") {
740
+ const continuePayload = {
741
+ customCommands: [
742
+ {
743
+ name: "vexp-context",
744
+ description: "Get vexp context for current task",
745
+ prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
746
+ },
747
+ ],
748
+ contextProviders: [
749
+ {
750
+ name: "vexp",
751
+ params: {},
752
+ },
753
+ ],
754
+ mcpServers: [
755
+ {
756
+ name: "vexp",
757
+ command: binaryPath,
758
+ args: ["mcp"],
759
+ type: "stdio",
760
+ },
761
+ ],
762
+ };
763
+ const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
764
+ action = mergeResult === "merged" ? "updated" : "skipped";
765
+ }
766
+ else {
767
+ action = appendOrCreate(configFilePath, content, version);
768
+ }
769
+ writtenConfigFiles.add(detector.configFile);
745
770
  }
746
- else {
747
- removeClaudeCodePreToolHooks(workspaceRoot);
771
+ // Write MCP config for agents with mcpConfigFile (project-level JSON)
772
+ if (detector.mcpConfigFile) {
773
+ const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
774
+ const isKiro = detector.agent === "Kiro";
775
+ // Windsurf and Kiro auto-approve vexp's tools so the agent isn't prompted
776
+ // on every run_pipeline call. Kiro's field is `autoApprove`; others use
777
+ // `alwaysAllow`.
778
+ const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
779
+ const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
780
+ // Kiro resolves the MCP `command` via PATH, and a macOS GUI launch has no
781
+ // `node` on PATH — so the `node <mcp-server.cjs>` transport silently fails
782
+ // to start and run_pipeline never appears (the agent then falls back to
783
+ // grep/sed). Point Kiro straight at the vexp binary (`vexp-core mcp`, which
784
+ // reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
785
+ // needed, an absolute path that always resolves.
786
+ const mcpSrv = isKiro ? undefined : mcpServerPath;
787
+ if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
788
+ mcpConfigs.push(detector.mcpConfigFile);
789
+ }
748
790
  }
749
- installClaudeCodeStopGate(workspaceRoot, binaryPath);
750
- installClaudeCodeSessionContext(workspaceRoot, binaryPath);
751
- if (hintResult) {
752
- results.push({
753
- agent: "Claude Code Hint",
754
- configFile: ".claude/hooks/vexp-hint.sh",
755
- content: vexpHintHookScript(binaryPath),
756
- alreadyExists: false,
757
- action: hintResult,
758
- });
791
+ // Windsurf / Devin Desktop: the two files the agent actually reads.
792
+ if (detector.agent === "Windsurf") {
793
+ if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
794
+ mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
795
+ if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
796
+ mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
797
+ if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
798
+ mcpConfigs.push(".devin/mcp_config.json");
799
+ removeDeadWindsurfMcpJson(workspaceRoot);
759
800
  }
760
- // Guard is OPT-IN from 2.3 (see GuardMode): strict installs the
761
- // PreToolUse deny hook; default removes any previously installed one.
762
- if (guardMode() === "strict") {
763
- const hookResult = installClaudeCodeHook(workspaceRoot);
764
- if (hookResult) {
801
+ // Claude Code: configure MCP in ~/.claude.json (user-scope, stdio)
802
+ if (detector.agent === "Claude Code") {
803
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
804
+ if (wrote)
805
+ mcpConfigs.push("~/.claude.json");
806
+ // Event-driven hint hook: default-on (non-blocking, fail-open — the
807
+ // 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
808
+ const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
809
+ installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
810
+ installClaudeCodeSearchHook(workspaceRoot, binaryPath);
811
+ // Both doors. Bounding either alone measured as no change at all: the
812
+ // work simply moves to the other one.
813
+ if (interventionMode() === "on") {
814
+ installClaudeCodeReadHint(workspaceRoot, binaryPath);
815
+ installClaudeCodeBashCap(workspaceRoot, binaryPath);
816
+ }
817
+ else {
818
+ removeClaudeCodePreToolHooks(workspaceRoot);
819
+ }
820
+ installClaudeCodeStopGate(workspaceRoot, binaryPath);
821
+ installClaudeCodeSessionContext(workspaceRoot, binaryPath);
822
+ if (hintResult) {
823
+ results.push({
824
+ agent: "Claude Code Hint",
825
+ // A committed script left as it is (K1) was not written: what
826
+ // changed is its registration.
827
+ configFile: hookScriptLeft(path.join(workspaceRoot, ".claude", "hooks", "vexp-hint.sh"))
828
+ ? ".claude/settings.json"
829
+ : ".claude/hooks/vexp-hint.sh",
830
+ content: vexpHintHookScript(binaryPath),
831
+ alreadyExists: false,
832
+ action: hintResult,
833
+ });
834
+ }
835
+ // Guard is OPT-IN from 2.3 (see GuardMode): strict installs the
836
+ // PreToolUse deny hook; default removes any previously installed one.
837
+ if (guardMode() === "strict") {
838
+ const hookResult = installClaudeCodeHook(workspaceRoot);
839
+ if (hookResult) {
840
+ results.push({
841
+ agent: "Claude Code Hook",
842
+ configFile: ".claude/hooks/vexp-guard.sh",
843
+ content: VEXP_GUARD_HOOK,
844
+ alreadyExists: false,
845
+ action: hookResult,
846
+ });
847
+ }
848
+ }
849
+ else if (uninstallClaudeCodeHook(workspaceRoot)) {
765
850
  results.push({
766
851
  agent: "Claude Code Hook",
767
852
  configFile: ".claude/hooks/vexp-guard.sh",
768
- content: VEXP_GUARD_HOOK,
853
+ content: "",
769
854
  alreadyExists: false,
770
- action: hookResult,
855
+ action: "removed",
771
856
  });
772
857
  }
773
858
  }
774
- else if (uninstallClaudeCodeHook(workspaceRoot)) {
775
- results.push({
776
- agent: "Claude Code Hook",
777
- configFile: ".claude/hooks/vexp-guard.sh",
778
- content: "",
779
- alreadyExists: false,
780
- action: "removed",
781
- });
859
+ // Codex: configure MCP in ~/.codex/config.toml (global, HTTP + bearer token)
860
+ if (detector.agent === "Codex") {
861
+ const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
862
+ if (wrote)
863
+ mcpConfigs.push("~/.codex/config.toml");
864
+ installCodexHintHook(workspaceRoot, binaryPath);
782
865
  }
783
- }
784
- // Codex: configure MCP in ~/.codex/config.toml (global, HTTP + bearer token)
785
- if (detector.agent === "Codex") {
786
- const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
787
- if (wrote)
788
- mcpConfigs.push("~/.codex/config.toml");
789
- installCodexHintHook(workspaceRoot, binaryPath);
790
- }
791
- // Antigravity: machine-global MCP config (~/.gemini/antigravity/mcp_config.json)
792
- if (detector.agent === "Antigravity") {
793
- const wrote = configureAntigravityGlobal(binaryPath, mcpServerPath);
794
- if (wrote)
795
- mcpConfigs.push("~/.gemini/antigravity/mcp_config.json");
796
- }
797
- // ZCode: `mcp.servers` in .zcode/config.json (or the .agents/mcp.json
798
- // compatibility file when that is what the project uses) + the
799
- // orientation hook in ZCode's USER config — see configureZcodeMcp.
800
- if (detector.agent === "ZCode") {
801
- const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
802
- if (wrote)
803
- mcpConfigs.push(wrote);
804
- const hint = installZcodeHintHook(binaryPath);
805
- if (hint) {
806
- results.push({
807
- agent: "ZCode Hint",
808
- configFile: "~/.zcode/cli/config.json",
809
- content: "",
810
- alreadyExists: hint === "updated",
811
- action: hint,
812
- });
866
+ // Antigravity: the project's .agents/mcp_config.json, pinned to this
867
+ // project (configureAntigravityProject). The dead machine-wide entries
868
+ // older setups left go at the end, whichever agents are set up.
869
+ if (detector.agent === "Antigravity") {
870
+ if (configureAntigravityProject(workspaceRoot, binaryPath))
871
+ mcpConfigs.push(ANTIGRAVITY_PROJECT_MCP);
813
872
  }
814
- }
815
- // GitHub Copilot: VS Code format (.vscode/mcp.json with "servers" key)
816
- if (detector.agent === "GitHub Copilot") {
817
- const mcpPath = path.join(workspaceRoot, ".vscode/mcp.json");
818
- if (writeVsCodeMcpConfig(mcpPath, binaryPath, mcpServerPath, workspaceRoot)) {
819
- mcpConfigs.push(".vscode/mcp.json");
873
+ // ZCode: `mcp.servers` in .zcode/config.json (or the .agents/mcp.json
874
+ // compatibility file when that is what the project uses) + the
875
+ // orientation hook in ZCode's USER config — see configureZcodeMcp.
876
+ if (detector.agent === "ZCode") {
877
+ const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
878
+ if (wrote)
879
+ mcpConfigs.push(wrote);
880
+ const hint = installZcodeHintHook(binaryPath);
881
+ if (hint) {
882
+ results.push({
883
+ agent: "ZCode Hint",
884
+ configFile: "~/.zcode/cli/config.json",
885
+ content: "",
886
+ alreadyExists: hint === "updated",
887
+ action: hint,
888
+ });
889
+ }
820
890
  }
821
- }
822
- // Zed: context_servers in .zed/settings.json
823
- if (detector.agent === "Zed") {
824
- const zedPath = path.join(workspaceRoot, ".zed/settings.json");
825
- if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot)) {
826
- mcpConfigs.push(".zed/settings.json");
891
+ // GitHub Copilot: VS Code format (.vscode/mcp.json with "servers" key)
892
+ // `.mcp.json` at the root is kept runnable, and when it defines vexp
893
+ // `.vscode/mcp.json` gets no entry of its own (configureCopilotMcp).
894
+ if (detector.agent === "GitHub Copilot") {
895
+ mcpConfigs.push(...configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath));
827
896
  }
828
- }
829
- // Kilo Code: MCP lives under the `mcp` key in kilo.jsonc (root or .kilo/),
830
- // not a separate mcp.json (that file is no longer read).
831
- if (detector.agent === "Kilo Code") {
832
- const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
833
- if (wrote)
834
- mcpConfigs.push(wrote);
835
- installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
836
- installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
837
- // Kilo v7 vendors opencode, so it takes the same guard plugin — the rules
838
- // markdown alone was demonstrably not enough (a reported session loaded
839
- // vexp.md, quoted it back, and still read five files by hand).
840
- if (guardMode() === "strict") {
841
- const kilo = installKiloPlugin(workspaceRoot);
842
- if (kilo.plugin) {
897
+ // Zed: context_servers in .zed/settings.json
898
+ if (detector.agent === "Zed") {
899
+ const zedPath = path.join(workspaceRoot, ".zed/settings.json");
900
+ if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot)) {
901
+ mcpConfigs.push(".zed/settings.json");
902
+ }
903
+ }
904
+ // Kilo Code: MCP lives under the `mcp` key in kilo.jsonc (root or .kilo/),
905
+ // not a separate mcp.json (that file is no longer read).
906
+ if (detector.agent === "Kilo Code") {
907
+ const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
908
+ if (wrote)
909
+ mcpConfigs.push(wrote);
910
+ installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
911
+ installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
912
+ // Kilo v7 vendors opencode, so it takes the same guard plugin — the rules
913
+ // markdown alone was demonstrably not enough (a reported session loaded
914
+ // vexp.md, quoted it back, and still read five files by hand).
915
+ if (guardMode() === "strict") {
916
+ const kilo = installKiloPlugin(workspaceRoot);
917
+ if (kilo.plugin) {
918
+ results.push({
919
+ agent: "Kilo Code Guard",
920
+ configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
921
+ content: VEXP_OPENCODE_GUARD,
922
+ alreadyExists: kilo.plugin === "updated",
923
+ action: kilo.plugin,
924
+ });
925
+ }
926
+ if (kilo.registered)
927
+ mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
928
+ }
929
+ else if (uninstallKiloPlugin(workspaceRoot)) {
843
930
  results.push({
844
931
  agent: "Kilo Code Guard",
845
932
  configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
846
- content: VEXP_OPENCODE_GUARD,
847
- alreadyExists: kilo.plugin === "updated",
848
- action: kilo.plugin,
933
+ content: "",
934
+ alreadyExists: false,
935
+ action: "removed",
849
936
  });
850
937
  }
851
- if (kilo.registered)
852
- mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
853
- }
854
- else if (uninstallKiloPlugin(workspaceRoot)) {
855
- results.push({
856
- agent: "Kilo Code Guard",
857
- configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
858
- content: "",
859
- alreadyExists: false,
860
- action: "removed",
861
- });
862
938
  }
863
- }
864
- // Cursor: preToolUse hook matching Grep. Text search only - Cursor's native
865
- // semantic search is not hookable.
866
- if (detector.agent === "Cursor") {
867
- if (guardMode() === "strict") {
868
- const cur = installCursorHook(workspaceRoot);
869
- if (cur.hook) {
939
+ // Cursor: preToolUse hook matching Grep. Text search only - Cursor's native
940
+ // semantic search is not hookable.
941
+ if (detector.agent === "Cursor") {
942
+ if (guardMode() === "strict") {
943
+ const cur = installCursorHook(workspaceRoot);
944
+ if (cur.hook) {
945
+ results.push({
946
+ agent: "Cursor Guard",
947
+ configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
948
+ content: VEXP_CURSOR_GUARD,
949
+ alreadyExists: cur.hook === "updated",
950
+ action: cur.hook,
951
+ });
952
+ }
953
+ if (cur.registered)
954
+ mcpConfigs.push(path.join(".cursor", "hooks.json"));
955
+ }
956
+ else if (uninstallCursorHook(workspaceRoot)) {
870
957
  results.push({
871
958
  agent: "Cursor Guard",
872
959
  configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
873
- content: VEXP_CURSOR_GUARD,
874
- alreadyExists: cur.hook === "updated",
875
- action: cur.hook,
960
+ content: "",
961
+ alreadyExists: false,
962
+ action: "removed",
963
+ });
964
+ }
965
+ // v5: the coupling on edit. Default-ON and independent of the guard —
966
+ // it blocks nothing, so there is no reason to make it opt-in.
967
+ const edit = installCursorEditHint(workspaceRoot, binaryPath);
968
+ if (edit) {
969
+ results.push({
970
+ agent: "Cursor Edit Hint",
971
+ // A committed script left as it is (K1) was not written: what
972
+ // changed is its registration.
973
+ configFile: hookScriptLeft(path.join(workspaceRoot, ".cursor", "hooks", "vexp-edit-hint.sh"))
974
+ ? path.join(".cursor", "hooks.json")
975
+ : path.join(".cursor", "hooks", "vexp-edit-hint.sh"),
976
+ content: "",
977
+ alreadyExists: edit === "updated",
978
+ action: edit,
876
979
  });
877
980
  }
878
- if (cur.registered)
879
- mcpConfigs.push(path.join(".cursor", "hooks.json"));
880
- }
881
- else if (uninstallCursorHook(workspaceRoot)) {
882
- results.push({
883
- agent: "Cursor Guard",
884
- configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
885
- content: "",
886
- alreadyExists: false,
887
- action: "removed",
888
- });
889
- }
890
- // v5: the coupling on edit. Default-ON and independent of the guard —
891
- // it blocks nothing, so there is no reason to make it opt-in.
892
- const edit = installCursorEditHint(workspaceRoot, binaryPath);
893
- if (edit) {
894
- results.push({
895
- agent: "Cursor Edit Hint",
896
- configFile: path.join(".cursor", "hooks", "vexp-edit-hint.sh"),
897
- content: "",
898
- alreadyExists: edit === "updated",
899
- action: edit,
900
- });
901
981
  }
902
- }
903
- // Cline: rules in .clinerules (generic template, handled by the writer
904
- // above); MCP registry is machine-global VS Code storage.
905
- if (detector.agent === "Cline") {
906
- const wrote = configureClineMcp(workspaceRoot, binaryPath);
907
- for (const w of wrote) {
908
- mcpConfigs.push(w);
982
+ // Cline: rules in .clinerules (generic template, handled by the writer
983
+ // above); MCP registry is machine-global VS Code storage.
984
+ if (detector.agent === "Cline") {
985
+ const wrote = configureClineMcp(workspaceRoot, binaryPath);
986
+ for (const w of wrote) {
987
+ mcpConfigs.push(w);
988
+ }
989
+ if (wrote.length === 0 && !clineInstalled())
990
+ noteClineMcpUnreachable();
909
991
  }
910
- if (wrote.length === 0)
911
- noteClineMcpUnreachable();
912
- }
913
- // Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
914
- // carries no `mcpConfigFile`, so without this call the generic writer above
915
- // skips it and setup registers no tool at all. Plus the guard plugin that
916
- // blocks grep/glob while the daemon is up (opencode's analogue of the
917
- // Claude Code PreToolUse hook) — which is only safe once MCP is registered.
918
- if (detector.agent === "Opencode") {
919
- const wroteMcp = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
920
- installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
921
- installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
922
- if (wroteMcp)
923
- mcpConfigs.push(wroteMcp);
924
- if (guardMode() === "strict") {
925
- const pluginResult = installOpencodePlugin(workspaceRoot);
926
- if (pluginResult) {
992
+ // Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
993
+ // carries no `mcpConfigFile`, so without this call the generic writer above
994
+ // skips it and setup registers no tool at all. Plus the guard plugin that
995
+ // blocks grep/glob while the daemon is up (opencode's analogue of the
996
+ // Claude Code PreToolUse hook) — which is only safe once MCP is registered.
997
+ if (detector.agent === "Opencode") {
998
+ const wroteMcp = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
999
+ installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1000
+ installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1001
+ if (wroteMcp)
1002
+ mcpConfigs.push(wroteMcp);
1003
+ if (guardMode() === "strict") {
1004
+ const pluginResult = installOpencodePlugin(workspaceRoot);
1005
+ if (pluginResult) {
1006
+ results.push({
1007
+ agent: "Opencode Guard",
1008
+ configFile: ".opencode/plugins/vexp-guard.js",
1009
+ content: VEXP_OPENCODE_GUARD,
1010
+ alreadyExists: pluginResult === "updated",
1011
+ action: pluginResult,
1012
+ });
1013
+ }
1014
+ }
1015
+ else if (uninstallOpencodePlugin(workspaceRoot)) {
927
1016
  results.push({
928
1017
  agent: "Opencode Guard",
929
1018
  configFile: ".opencode/plugins/vexp-guard.js",
930
- content: VEXP_OPENCODE_GUARD,
931
- alreadyExists: pluginResult === "updated",
932
- action: pluginResult,
1019
+ content: "",
1020
+ alreadyExists: false,
1021
+ action: "removed",
933
1022
  });
934
1023
  }
935
1024
  }
936
- else if (uninstallOpencodePlugin(workspaceRoot)) {
937
- results.push({
938
- agent: "Opencode Guard",
939
- configFile: ".opencode/plugins/vexp-guard.js",
940
- content: "",
941
- alreadyExists: false,
942
- action: "removed",
943
- });
944
- }
1025
+ results.push({
1026
+ agent: detector.agent,
1027
+ configFile: detector.configFile,
1028
+ content,
1029
+ alreadyExists,
1030
+ action,
1031
+ });
1032
+ }
1033
+ catch (err) {
1034
+ noteAgentSetupStopped(detector.agent, err);
945
1035
  }
946
- results.push({
947
- agent: detector.agent,
948
- configFile: detector.configFile,
949
- content,
950
- alreadyExists,
951
- action,
952
- });
953
1036
  }
1037
+ removeDeadAntigravityEntriesAtSetup();
954
1038
  return { agents: results, mcpConfigs };
955
1039
  }
956
1040
  /**
@@ -1026,178 +1110,197 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
1026
1110
  const detector = withCursorTarget(detected, workspaceRoot);
1027
1111
  if (!selectedAgentNames.includes(detector.agent))
1028
1112
  continue;
1029
- // Create agent directory if it doesn't exist. Skip for file-based
1030
- // detectPaths: extension-bearing markers (AGENTS.md) and extensionless
1031
- // file markers where the marker IS the config file (.clinerules) —
1032
- // mkdir-ing those creates a directory where a file must go (EISDIR).
1033
- const detectAbsPath = path.join(workspaceRoot, detector.detectPath);
1034
- if (!fs.existsSync(detectAbsPath) &&
1035
- !path.extname(detector.detectPath) &&
1036
- detector.detectPath !== detector.configFile) {
1037
- fs.mkdirSync(detectAbsPath, { recursive: true });
1038
- }
1039
- // Create parent directory for configFile (handles nested paths like .kiro/steering/vexp.md)
1040
- const configFilePath = path.join(workspaceRoot, detector.configFile);
1041
- const configDir = path.dirname(configFilePath);
1042
- if (!fs.existsSync(configDir)) {
1043
- fs.mkdirSync(configDir, { recursive: true });
1113
+ // A folder of the agent's that is a link to nothing (danglingAgentFolder):
1114
+ // mkdirSync and writeFileSync both throw ENOENT there, and the exception
1115
+ // left this loop, so the setup failed for every agent, not only for the
1116
+ // one whose folder it was (bug at 3.3.9). That agent alone is skipped,
1117
+ // and the end of setup says why (takeUnreachableTargets).
1118
+ const dangling = danglingAgentFolder(detector, workspaceRoot);
1119
+ if (dangling) {
1120
+ noteDanglingAgentFolder(dangling);
1121
+ continue;
1044
1122
  }
1045
- // Create parent directory for MCP config (handles .codex/, .kiro/settings/, etc.)
1046
- if (detector.mcpConfigFile) {
1047
- const mcpDir = path.dirname(path.join(workspaceRoot, detector.mcpConfigFile));
1048
- if (!fs.existsSync(mcpDir)) {
1049
- fs.mkdirSync(mcpDir, { recursive: true });
1123
+ // Anything else that throws for one agent stops that agent only, and
1124
+ // the end of setup says so (noteAgentSetupStopped): the agents after
1125
+ // it are set up all the same.
1126
+ try {
1127
+ // Create agent directory if it doesn't exist. Skip for file-based
1128
+ // detectPaths: extension-bearing markers (AGENTS.md) and extensionless
1129
+ // file markers where the marker IS the config file (.clinerules) —
1130
+ // mkdir-ing those creates a directory where a file must go (EISDIR).
1131
+ const detectAbsPath = path.join(workspaceRoot, detector.detectPath);
1132
+ if (!fs.existsSync(detectAbsPath) &&
1133
+ !path.extname(detector.detectPath) &&
1134
+ detector.detectPath !== detector.configFile) {
1135
+ fs.mkdirSync(detectAbsPath, { recursive: true });
1050
1136
  }
1051
- }
1052
- // Same sweep as the other writer: the Windsurf rules moved out of
1053
- // `.windsurf/rules.md`, which Cascade never read.
1054
- if (detector.agent === "Windsurf") {
1055
- removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"));
1056
- }
1057
- const alreadyExists = fs.existsSync(configFilePath);
1058
- const content = generateAgentConfig(detector.templateName, {
1059
- workspaceRoot,
1060
- binaryPath,
1061
- configFile: detector.configFile,
1062
- version,
1063
- });
1064
- let action = "skipped";
1065
- if (!writtenConfigFiles.has(detector.configFile)) {
1066
- if (detector.templateName === "continue") {
1067
- const continuePayload = {
1068
- customCommands: [
1069
- {
1070
- name: "vexp-context",
1071
- description: "Get vexp context for current task",
1072
- prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
1073
- },
1074
- ],
1075
- mcpServers: [
1076
- { name: "vexp", command: binaryPath, args: ["mcp"], type: "stdio" },
1077
- ],
1078
- };
1079
- const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
1080
- action = mergeResult === "merged" ? "updated" : "skipped";
1137
+ // Create parent directory for configFile (handles nested paths like .kiro/steering/vexp.md)
1138
+ const configFilePath = path.join(workspaceRoot, detector.configFile);
1139
+ const configDir = path.dirname(configFilePath);
1140
+ if (!fs.existsSync(configDir)) {
1141
+ fs.mkdirSync(configDir, { recursive: true });
1081
1142
  }
1082
- else {
1083
- action = appendOrCreate(configFilePath, content, version);
1143
+ // Create parent directory for MCP config (handles .codex/, .kiro/settings/, etc.)
1144
+ // Not through a link out of the project: the writer leaves that file
1145
+ // alone and says so (projectWriteLanding), and creates nothing there.
1146
+ if (detector.mcpConfigFile) {
1147
+ const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
1148
+ const mcpDir = path.dirname(mcpConfigPath);
1149
+ if (!fs.existsSync(mcpDir) && projectWriteLanding(mcpConfigPath, workspaceRoot).ok) {
1150
+ fs.mkdirSync(mcpDir, { recursive: true });
1151
+ }
1084
1152
  }
1085
- writtenConfigFiles.add(detector.configFile);
1086
- }
1087
- if (detector.mcpConfigFile) {
1088
- const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
1089
- const isKiro = detector.agent === "Kiro";
1090
- // See configureAgents(): Kiro auto-approves under `autoApprove` and is
1091
- // pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
1092
- // mcpServerPath.
1093
- const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
1094
- const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
1095
- const mcpSrv = isKiro ? undefined : mcpServerPath;
1096
- if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
1097
- mcpConfigs.push(detector.mcpConfigFile);
1098
- }
1099
- // Windsurf / Devin Desktop: see configureAgents().
1100
- if (detector.agent === "Windsurf") {
1101
- if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
1102
- mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
1103
- if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
1104
- mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
1105
- if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
1106
- mcpConfigs.push(".devin/mcp_config.json");
1107
- removeDeadWindsurfMcpJson(workspaceRoot);
1108
- }
1109
- if (detector.agent === "Claude Code") {
1110
- const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
1111
- if (wrote)
1112
- mcpConfigs.push("~/.claude.json");
1113
- installClaudeCodeHintHook(workspaceRoot, binaryPath);
1114
- installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
1115
- if (interventionMode() === "on") {
1116
- installClaudeCodeReadHint(workspaceRoot, binaryPath);
1117
- installClaudeCodeBashCap(workspaceRoot, binaryPath);
1153
+ // Same sweep as the other writer: the Windsurf rules moved out of
1154
+ // `.windsurf/rules.md`, which Cascade never read.
1155
+ if (detector.agent === "Windsurf") {
1156
+ removeVexpSection(path.join(workspaceRoot, ".windsurf", "rules.md"), "Windsurf");
1118
1157
  }
1119
- else {
1120
- removeClaudeCodePreToolHooks(workspaceRoot);
1158
+ const alreadyExists = fs.existsSync(configFilePath);
1159
+ const content = generateAgentConfig(detector.templateName, {
1160
+ workspaceRoot,
1161
+ binaryPath,
1162
+ configFile: detector.configFile,
1163
+ version,
1164
+ });
1165
+ let action = "skipped";
1166
+ if (!writtenConfigFiles.has(detector.configFile)) {
1167
+ if (detector.templateName === "continue") {
1168
+ const continuePayload = {
1169
+ customCommands: [
1170
+ {
1171
+ name: "vexp-context",
1172
+ description: "Get vexp context for current task",
1173
+ prompt: "{{{ input }}}\n\nFirst, call run_pipeline with the above task description.",
1174
+ },
1175
+ ],
1176
+ mcpServers: [
1177
+ { name: "vexp", command: binaryPath, args: ["mcp"], type: "stdio" },
1178
+ ],
1179
+ };
1180
+ const mergeResult = mergeJsonConfig(configFilePath, continuePayload, version);
1181
+ action = mergeResult === "merged" ? "updated" : "skipped";
1182
+ }
1183
+ else {
1184
+ action = appendOrCreate(configFilePath, content, version);
1185
+ }
1186
+ writtenConfigFiles.add(detector.configFile);
1121
1187
  }
1122
- installClaudeCodeStopGate(workspaceRoot, binaryPath);
1123
- installClaudeCodeSessionContext(workspaceRoot, binaryPath);
1124
- if (guardMode() === "strict")
1125
- installClaudeCodeHook(workspaceRoot);
1126
- else
1127
- uninstallClaudeCodeHook(workspaceRoot);
1128
- }
1129
- if (detector.agent === "Codex") {
1130
- const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
1131
- if (wrote)
1132
- mcpConfigs.push("~/.codex/config.toml");
1133
- installCodexHintHook(workspaceRoot, binaryPath);
1134
- }
1135
- if (detector.agent === "Antigravity") {
1136
- const wrote = configureAntigravityGlobal(binaryPath, mcpServerPath);
1137
- if (wrote)
1138
- mcpConfigs.push("~/.gemini/antigravity/mcp_config.json");
1139
- }
1140
- if (detector.agent === "ZCode") {
1141
- const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
1142
- if (wrote)
1143
- mcpConfigs.push(wrote);
1144
- installZcodeHintHook(binaryPath);
1145
- }
1146
- if (detector.agent === "GitHub Copilot") {
1147
- const mcpPath = path.join(workspaceRoot, ".vscode/mcp.json");
1148
- if (writeVsCodeMcpConfig(mcpPath, binaryPath, mcpServerPath, workspaceRoot))
1149
- mcpConfigs.push(".vscode/mcp.json");
1150
- }
1151
- if (detector.agent === "Zed") {
1152
- const zedPath = path.join(workspaceRoot, ".zed/settings.json");
1153
- if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
1154
- mcpConfigs.push(".zed/settings.json");
1155
- }
1156
- if (detector.agent === "Cline") {
1157
- const wrote = configureClineMcp(workspaceRoot, binaryPath);
1158
- for (const w of wrote)
1159
- mcpConfigs.push(w);
1160
- if (wrote.length === 0)
1161
- noteClineMcpUnreachable();
1162
- }
1163
- if (detector.agent === "Opencode") {
1164
- const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
1165
- if (wrote)
1166
- mcpConfigs.push(wrote);
1167
- installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1168
- installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1169
- if (guardMode() === "strict")
1170
- installOpencodePlugin(workspaceRoot);
1171
- else
1172
- uninstallOpencodePlugin(workspaceRoot);
1173
- }
1174
- if (detector.agent === "Cursor") {
1175
- if (interventionMode() === "on") {
1176
- installCursorReadHint(workspaceRoot, binaryPath);
1177
- installCursorBashCap(workspaceRoot, binaryPath);
1188
+ if (detector.mcpConfigFile) {
1189
+ const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
1190
+ const isKiro = detector.agent === "Kiro";
1191
+ // See configureAgents(): Kiro auto-approves under `autoApprove` and is
1192
+ // pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
1193
+ // mcpServerPath.
1194
+ const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
1195
+ const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
1196
+ const mcpSrv = isKiro ? undefined : mcpServerPath;
1197
+ if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
1198
+ mcpConfigs.push(detector.mcpConfigFile);
1178
1199
  }
1179
- if (guardMode() === "strict")
1180
- installCursorHook(workspaceRoot);
1181
- else
1182
- uninstallCursorHook(workspaceRoot);
1200
+ // Windsurf / Devin Desktop: see configureAgents().
1201
+ if (detector.agent === "Windsurf") {
1202
+ if (configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot))
1203
+ mcpConfigs.push("~/.codeium/windsurf/mcp_config.json");
1204
+ if (configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot))
1205
+ mcpConfigs.push("~/.codeium/windsurf-next/mcp_config.json");
1206
+ if (writeMcpConfig(path.join(workspaceRoot, ".devin", "mcp_config.json"), binaryPath, undefined, mcpServerPath, workspaceRoot))
1207
+ mcpConfigs.push(".devin/mcp_config.json");
1208
+ removeDeadWindsurfMcpJson(workspaceRoot);
1209
+ }
1210
+ if (detector.agent === "Claude Code") {
1211
+ const wrote = configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot, version);
1212
+ if (wrote)
1213
+ mcpConfigs.push("~/.claude.json");
1214
+ installClaudeCodeHintHook(workspaceRoot, binaryPath);
1215
+ installClaudeCodeSubagentHook(workspaceRoot, binaryPath);
1216
+ if (interventionMode() === "on") {
1217
+ installClaudeCodeReadHint(workspaceRoot, binaryPath);
1218
+ installClaudeCodeBashCap(workspaceRoot, binaryPath);
1219
+ }
1220
+ else {
1221
+ removeClaudeCodePreToolHooks(workspaceRoot);
1222
+ }
1223
+ installClaudeCodeStopGate(workspaceRoot, binaryPath);
1224
+ installClaudeCodeSessionContext(workspaceRoot, binaryPath);
1225
+ if (guardMode() === "strict")
1226
+ installClaudeCodeHook(workspaceRoot);
1227
+ else
1228
+ uninstallClaudeCodeHook(workspaceRoot);
1229
+ }
1230
+ if (detector.agent === "Codex") {
1231
+ const wrote = configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot);
1232
+ if (wrote)
1233
+ mcpConfigs.push("~/.codex/config.toml");
1234
+ installCodexHintHook(workspaceRoot, binaryPath);
1235
+ }
1236
+ if (detector.agent === "Antigravity") {
1237
+ if (configureAntigravityProject(workspaceRoot, binaryPath))
1238
+ mcpConfigs.push(ANTIGRAVITY_PROJECT_MCP);
1239
+ }
1240
+ if (detector.agent === "ZCode") {
1241
+ const wrote = configureZcodeMcp(workspaceRoot, binaryPath);
1242
+ if (wrote)
1243
+ mcpConfigs.push(wrote);
1244
+ installZcodeHintHook(binaryPath);
1245
+ }
1246
+ if (detector.agent === "GitHub Copilot") {
1247
+ mcpConfigs.push(...configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath));
1248
+ }
1249
+ if (detector.agent === "Zed") {
1250
+ const zedPath = path.join(workspaceRoot, ".zed/settings.json");
1251
+ if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
1252
+ mcpConfigs.push(".zed/settings.json");
1253
+ }
1254
+ if (detector.agent === "Cline") {
1255
+ const wrote = configureClineMcp(workspaceRoot, binaryPath);
1256
+ for (const w of wrote)
1257
+ mcpConfigs.push(w);
1258
+ if (wrote.length === 0 && !clineInstalled())
1259
+ noteClineMcpUnreachable();
1260
+ }
1261
+ if (detector.agent === "Opencode") {
1262
+ const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
1263
+ if (wrote)
1264
+ mcpConfigs.push(wrote);
1265
+ installOpencodeHintPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1266
+ installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".opencode/plugin");
1267
+ if (guardMode() === "strict")
1268
+ installOpencodePlugin(workspaceRoot);
1269
+ else
1270
+ uninstallOpencodePlugin(workspaceRoot);
1271
+ }
1272
+ if (detector.agent === "Cursor") {
1273
+ if (interventionMode() === "on") {
1274
+ installCursorReadHint(workspaceRoot, binaryPath);
1275
+ installCursorBashCap(workspaceRoot, binaryPath);
1276
+ }
1277
+ if (guardMode() === "strict")
1278
+ installCursorHook(workspaceRoot);
1279
+ else
1280
+ uninstallCursorHook(workspaceRoot);
1281
+ }
1282
+ // Kilo carries no `mcpConfigFile` (its MCP lives under the `mcp` key inside
1283
+ // kilo.jsonc), so without this branch the generic writer above skips it and
1284
+ // picking Kilo explicitly used to write the rules file and NOTHING else — an
1285
+ // agent told "always call run_pipeline" while the tool was never registered.
1286
+ if (detector.agent === "Kilo Code") {
1287
+ const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
1288
+ if (wrote)
1289
+ mcpConfigs.push(wrote);
1290
+ installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
1291
+ installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
1292
+ if (guardMode() === "strict")
1293
+ installKiloPlugin(workspaceRoot);
1294
+ else
1295
+ uninstallKiloPlugin(workspaceRoot);
1296
+ }
1297
+ results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
1183
1298
  }
1184
- // Kilo carries no `mcpConfigFile` (its MCP lives under the `mcp` key inside
1185
- // kilo.jsonc), so without this branch the generic writer above skips it and
1186
- // picking Kilo explicitly used to write the rules file and NOTHING else — an
1187
- // agent told "always call run_pipeline" while the tool was never registered.
1188
- if (detector.agent === "Kilo Code") {
1189
- const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
1190
- if (wrote)
1191
- mcpConfigs.push(wrote);
1192
- installOpencodeHintPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
1193
- installOpencodeCompressPlugin(workspaceRoot, binaryPath, ".kilo/plugin");
1194
- if (guardMode() === "strict")
1195
- installKiloPlugin(workspaceRoot);
1196
- else
1197
- uninstallKiloPlugin(workspaceRoot);
1299
+ catch (err) {
1300
+ noteAgentSetupStopped(detector.agent, err);
1198
1301
  }
1199
- results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
1200
1302
  }
1303
+ removeDeadAntigravityEntriesAtSetup();
1201
1304
  return { agents: results, mcpConfigs };
1202
1305
  }
1203
1306
  // ---------------------------------------------------------------------------
@@ -1263,8 +1366,42 @@ export function parseJsonc(text) {
1263
1366
  }
1264
1367
  out += c;
1265
1368
  }
1266
- out = out.replace(/,(\s*[}\]])/g, "$1");
1267
- return JSON.parse(out);
1369
+ return JSON.parse(stripTrailingCommas(out));
1370
+ }
1371
+ /**
1372
+ * Drop each `,` that only whitespace separates from a closing `}` or `]`,
1373
+ * outside string literals: a `,]` inside another server's argument is the
1374
+ * user's text and stays (a regex over the whole text took it out, and the
1375
+ * rewrite saved the changed string). Comments are gone by now. Lockstep
1376
+ * with the CLI/extension twin and with vexp-core's strip_trailing_commas.
1377
+ */
1378
+ function stripTrailingCommas(text) {
1379
+ let out = "";
1380
+ let inString = false;
1381
+ for (let i = 0; i < text.length; i++) {
1382
+ const c = text[i];
1383
+ if (inString) {
1384
+ out += c;
1385
+ if (c === "\\") {
1386
+ out += text[i + 1] ?? "";
1387
+ i++;
1388
+ }
1389
+ else if (c === '"')
1390
+ inString = false;
1391
+ continue;
1392
+ }
1393
+ if (c === '"')
1394
+ inString = true;
1395
+ else if (c === ",") {
1396
+ let j = i + 1;
1397
+ while (j < text.length && /\s/.test(text[j]))
1398
+ j++;
1399
+ if (text[j] === "}" || text[j] === "]")
1400
+ continue;
1401
+ }
1402
+ out += c;
1403
+ }
1404
+ return out;
1268
1405
  }
1269
1406
  /**
1270
1407
  * Read a JSON/JSONC config file without ever risking user data. When the file
@@ -1283,13 +1420,24 @@ export function readJsonConfigSafe(filePath) {
1283
1420
  }
1284
1421
  if (raw.trim() === "")
1285
1422
  return { data: {}, ok: true, existed: true };
1423
+ // Strict JSON first, and the JSONC walk only for a file that needs it:
1424
+ // what JSON.parse reads is what the file says, and a JSONC pass may only
1425
+ // ever widen that. Lockstep with the extension's readJsonConfigSafe.
1426
+ let parsed;
1286
1427
  try {
1287
- const parsed = parseJsonc(raw);
1288
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1289
- return { data: parsed, ok: true, existed: true };
1428
+ parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
1429
+ }
1430
+ catch {
1431
+ try {
1432
+ parsed = parseJsonc(raw);
1433
+ }
1434
+ catch {
1435
+ return { data: {}, ok: false, existed: true };
1290
1436
  }
1291
1437
  }
1292
- catch { /* fall through to ok:false */ }
1438
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1439
+ return { data: parsed, ok: true, existed: true };
1440
+ }
1293
1441
  return { data: {}, ok: false, existed: true };
1294
1442
  }
1295
1443
  /**
@@ -1665,8 +1813,10 @@ function backupConfig(filePath) {
1665
1813
  // Older versions left the copy next to the file; inside a workspace that
1666
1814
  // sibling is exactly what git shows as untracked. Retire it now that the
1667
1815
  // fresh copy lives in .vexp/backups/.
1816
+ // One committed to the repository stays (committedCleanupBlocked's rule;
1817
+ // no agent to name for a backup).
1668
1818
  const legacy = path.resolve(`${filePath}.vexp-bak`);
1669
- if (legacy !== dest && fs.existsSync(legacy))
1819
+ if (legacy !== dest && fs.existsSync(legacy) && !isGitTracked(legacy))
1670
1820
  fs.unlinkSync(legacy);
1671
1821
  }
1672
1822
  catch { /* non-fatal */ }
@@ -1679,10 +1829,47 @@ function backupConfig(filePath) {
1679
1829
  * global config files where cross-process races are possible.
1680
1830
  */
1681
1831
  function atomicWriteFileSync(filePath, content) {
1682
- const tmp = `${filePath}.vexp-tmp-${process.pid}`;
1832
+ // A path that is a link to a file is written where the link leads: the
1833
+ // temp file goes next to the real target and is renamed onto it, so the
1834
+ // link stays and its target gets the new content, with the permissions it
1835
+ // had. Renamed over the path itself, the link was replaced by a regular
1836
+ // file, and a dotfile manager's repository (stow, chezmoi and home-manager
1837
+ // link ~/.claude.json, ~/.codex/config.toml and the like into it) silently
1838
+ // stopped receiving the user's updates. A link to nothing is replaced by
1839
+ // the new regular file, as before: there is no target to keep, and
1840
+ // creating one wherever the link points is not vexp's to do.
1841
+ //
1842
+ // A link whose target cannot be written is replaced too, as up to 3.3.9:
1843
+ // home-manager links ~/.codex/config.toml into the read-only Nix store,
1844
+ // so the write there fails (EACCES, EROFS) where the rename over the link
1845
+ // worked, and the agent lost its setup (review, 2026-11-07, round 5). The
1846
+ // new file takes the target's permissions with the owner's write bit, so
1847
+ // it is never more open than the target was. Lockstep with the
1848
+ // extension's atomicWriteFileSync.
1849
+ const linked = linkedFileTarget(filePath);
1850
+ let mode;
1851
+ if (linked) {
1852
+ try {
1853
+ // The temp file is created with the default mode; the link's target
1854
+ // keeps the one it had (a 0600 file stays private).
1855
+ mode = fs.statSync(linked).mode & 0o7777;
1856
+ writeThroughTemp(linked, content, mode);
1857
+ return;
1858
+ }
1859
+ catch {
1860
+ /* the target cannot be written: the link is replaced, below */
1861
+ }
1862
+ }
1863
+ writeThroughTemp(filePath, content, mode === undefined ? undefined : mode | 0o200);
1864
+ }
1865
+ /** Temp file beside `target`, renamed onto it; a direct write when the rename cannot be done. */
1866
+ function writeThroughTemp(target, content, mode) {
1867
+ const tmp = `${target}.vexp-tmp-${process.pid}`;
1683
1868
  try {
1684
1869
  fs.writeFileSync(tmp, content, "utf-8");
1685
- fs.renameSync(tmp, filePath);
1870
+ if (mode !== undefined)
1871
+ fs.chmodSync(tmp, mode);
1872
+ fs.renameSync(tmp, target);
1686
1873
  }
1687
1874
  catch {
1688
1875
  // Cross-device rename or a Windows edge — fall back to a direct write.
@@ -1690,7 +1877,24 @@ function atomicWriteFileSync(filePath, content) {
1690
1877
  fs.unlinkSync(tmp);
1691
1878
  }
1692
1879
  catch { /* ignore */ }
1693
- fs.writeFileSync(filePath, content, "utf-8");
1880
+ fs.writeFileSync(target, content, "utf-8");
1881
+ }
1882
+ }
1883
+ /**
1884
+ * The real path of the file `p` leads to when `p` is a link (or a chain of
1885
+ * links) to a file; undefined when `p` is no link, a link to nothing, or a
1886
+ * link to something other than a file, which atomicWriteFileSync replaces
1887
+ * as it always did. Lockstep with the extension's linkedFileTarget.
1888
+ */
1889
+ function linkedFileTarget(p) {
1890
+ try {
1891
+ if (!fs.lstatSync(p).isSymbolicLink())
1892
+ return undefined;
1893
+ const real = fs.realpathSync(p);
1894
+ return fs.statSync(real).isFile() ? real : undefined;
1895
+ }
1896
+ catch {
1897
+ return undefined;
1694
1898
  }
1695
1899
  }
1696
1900
  /** Warn that we refused to touch an unparseable config file. */
@@ -1769,6 +1973,223 @@ function warnUnparseable(filePath) {
1769
1973
  }
1770
1974
  process.stderr.write(` [!] ${filePath} could not be parsed - leaving it untouched. Fix the file or add vexp manually, then re-run setup.\n`);
1771
1975
  }
1976
+ /**
1977
+ * Is the key an MCP writer puts vexp's entry under there, and not an object
1978
+ * (an array, a string, a number, null)? vexp's entry cannot go in without
1979
+ * replacing the user's value: an array took it as a property that
1980
+ * JSON.stringify drops, so the file was rewritten without it and setup
1981
+ * reported an entry it had not written; a string threw; null was replaced.
1982
+ * Every JSON MCP writer leaves such a file byte for byte and says so
1983
+ * (leftUntouched). Lockstep with the extension and vexp-core.
1984
+ */
1985
+ function containerMisshapen(data, key) {
1986
+ if (!Object.prototype.hasOwnProperty.call(data, key))
1987
+ return false;
1988
+ return !asPlainObject(data[key]);
1989
+ }
1990
+ /** A JSON object, not an array or null. */
1991
+ function asPlainObject(v) {
1992
+ return !!v && typeof v === "object" && !Array.isArray(v);
1993
+ }
1994
+ /**
1995
+ * Report a file a writer left untouched because the key vexp's entry goes
1996
+ * under is not an object (containerMisshapen), at the end of setup
1997
+ * (takeUnreachableTargets), in the words the extension's setup report
1998
+ * shows. `key` is the path of the key in the file (`mcp.servers`). Returns
1999
+ * false: nothing was written.
2000
+ */
2001
+ function leftUntouched(filePath, key, shape = "an object") {
2002
+ const msg = `${filePath} has an ${key} that is not ${shape} - left untouched`;
2003
+ if (!unreachableTargets.includes(msg))
2004
+ unreachableTargets.push(msg);
2005
+ return false;
2006
+ }
2007
+ /**
2008
+ * Where a path really is: its real path when it is there, else the real
2009
+ * path of its nearest ancestor that is there, joined with the rest. A write
2010
+ * lands there, since writeFileSync and mkdirSync follow every link on the
2011
+ * way. undefined when the way goes through a link to nothing (or a loop):
2012
+ * a write there would create the link's target, wherever that is.
2013
+ * Lockstep with the extension's landingOf.
2014
+ */
2015
+ function landingOf(p) {
2016
+ // On macOS the system's realpath(3) (fs.realpathSync.native): its file
2017
+ // systems are case-insensitive and normalization-insensitive by default,
2018
+ // and realpath(3) returns each name as it is on disk, while the JS walk
2019
+ // keeps a link's target as written. Compared byte for byte, a link that
2020
+ // stays in the project but spells its folder in another letter case (or
2021
+ // the other Unicode form) read as leading out of it (review, 2026-11-07,
2022
+ // round 4); vexp-core's canonicalize is realpath(3) too. Elsewhere the JS
2023
+ // walk, as before: Linux file systems are case-sensitive, and on Windows
2024
+ // path.relative folds case. A native call that fails leaves it to the JS
2025
+ // walk, which throws the same for a link to nothing.
2026
+ const realpath = (q) => {
2027
+ if (process.platform === "darwin") {
2028
+ try {
2029
+ return fs.realpathSync.native(q);
2030
+ }
2031
+ catch {
2032
+ /* the JS walk decides */
2033
+ }
2034
+ }
2035
+ return fs.realpathSync(q);
2036
+ };
2037
+ const rest = [];
2038
+ let at = path.resolve(p);
2039
+ for (;;) {
2040
+ let st;
2041
+ try {
2042
+ st = fs.lstatSync(at);
2043
+ }
2044
+ catch {
2045
+ st = undefined; // not there: its parent decides
2046
+ }
2047
+ if (st) {
2048
+ try {
2049
+ return path.join(realpath(at), ...rest);
2050
+ }
2051
+ catch {
2052
+ // lstat found it, so only a link can fail to resolve; anything
2053
+ // else (a folder that cannot be read) is compared as written.
2054
+ return st.isSymbolicLink() ? undefined : path.join(at, ...rest);
2055
+ }
2056
+ }
2057
+ const parent = path.dirname(at);
2058
+ if (parent === at)
2059
+ return path.join(at, ...rest);
2060
+ rest.unshift(path.basename(at));
2061
+ at = parent;
2062
+ }
2063
+ }
2064
+ /**
2065
+ * Where a write to `file`, a file inside `workspaceRoot`, would land
2066
+ * (landingOf), checked against the root's own real path. Inside: ok, and
2067
+ * the writer writes as before, through an inner link, which stays a link
2068
+ * (a repository that keeps one MCP file and links it where each tool reads
2069
+ * it). Outside the project, through a link of the file or of a folder on
2070
+ * its way: refused. Through a link to nothing: refused, since a write
2071
+ * would create its target. Reads nothing but the file system's links.
2072
+ * ASCII texts, word for word the extension's (projectWriteLanding) and
2073
+ * vexp-core's.
2074
+ */
2075
+ export function projectWriteLanding(file, workspaceRoot) {
2076
+ const landing = landingOf(file);
2077
+ if (landing === undefined)
2078
+ return { ok: false, refuse: `${file} is a link to a file that does not exist - left untouched` };
2079
+ const root = landingOf(workspaceRoot) ?? path.resolve(workspaceRoot);
2080
+ // path.relative folds letter case on Windows, as the file system does.
2081
+ const rel = path.relative(root, landing);
2082
+ if (rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel))
2083
+ return { ok: true, landing };
2084
+ return { ok: false, refuse: `${file} leads outside this project through a link (${landing}) - left untouched` };
2085
+ }
2086
+ export function projectFileLink(file, workspaceRoot) {
2087
+ if (projectWriteLanding(file, workspaceRoot).ok)
2088
+ return undefined;
2089
+ const landing = landingOf(file);
2090
+ return landing === undefined ? { dangling: true } : { outside: landing };
2091
+ }
2092
+ /**
2093
+ * Is `file` a project file a pinned writer must check first: a root was
2094
+ * given (the entry is pinned to it) and the file is inside it as spelled?
2095
+ * A writer handed a file elsewhere writes it as it did before.
2096
+ */
2097
+ function pinnedProjectFile(file, workspaceRoot) {
2098
+ if (!workspaceRoot)
2099
+ return false;
2100
+ const rel = path.relative(path.resolve(workspaceRoot), path.resolve(file));
2101
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
2102
+ }
2103
+ /**
2104
+ * True when the write to `file` must not happen (projectWriteLanding); the
2105
+ * reason goes to the end of setup (takeUnreachableTargets). Called at the
2106
+ * top of each pinned project writer, before it reads the file.
2107
+ */
2108
+ function projectWriteRefused(file, workspaceRoot) {
2109
+ const l = projectWriteLanding(file, workspaceRoot);
2110
+ if (l.ok)
2111
+ return false;
2112
+ if (!unreachableTargets.includes(l.refuse))
2113
+ unreachableTargets.push(l.refuse);
2114
+ return true;
2115
+ }
2116
+ /**
2117
+ * The first link to nothing among the folders of `rel` under `root`,
2118
+ * walking down from the root, as an absolute path; undefined when there is
2119
+ * none. A folder that is not there ends the walk: nothing under it is
2120
+ * there either. A loop counts as a link to nothing, as in landingOf.
2121
+ * Lockstep with the extension.
2122
+ */
2123
+ function danglingFolderOnWay(root, rel) {
2124
+ let at = path.resolve(root);
2125
+ for (const part of rel.split(/[\\/]/)) {
2126
+ if (part === "" || part === ".")
2127
+ continue;
2128
+ at = path.join(at, part);
2129
+ let st;
2130
+ try {
2131
+ st = fs.lstatSync(at);
2132
+ }
2133
+ catch {
2134
+ return undefined;
2135
+ }
2136
+ if (!st.isSymbolicLink())
2137
+ continue;
2138
+ try {
2139
+ fs.statSync(at);
2140
+ }
2141
+ catch {
2142
+ return at;
2143
+ }
2144
+ }
2145
+ return undefined;
2146
+ }
2147
+ /**
2148
+ * The agent's folder in the workspace (`.cursor`), or a folder on the way
2149
+ * to its instruction file or its project MCP file, that is a link to
2150
+ * nothing: a dotfile manager's link whose checkout is not on this machine,
2151
+ * a folder moved away. mkdirSync throws ENOENT there even with
2152
+ * `recursive`, and so does a write under it, and the exception left the
2153
+ * setup loop: `vexp setup` failed for every agent selected, not only for
2154
+ * the one whose folder it was (bug at 3.3.9). Setup skips that agent,
2155
+ * writes nothing for it and creates nothing, the link's target included:
2156
+ * where it leads is the user's. Lockstep with the extension's
2157
+ * danglingAgentFolder and vexp-core's dangling_folder_on_way.
2158
+ */
2159
+ export function danglingAgentFolder(d, workspaceRoot) {
2160
+ // A folder marker is the agent's folder; a file marker's folder is the
2161
+ // one it sits in (.codex for .codex/config.toml, the root for AGENTS.md).
2162
+ const folderMarker = !path.extname(d.detectPath) && d.detectPath !== d.configFile;
2163
+ const rels = [folderMarker ? d.detectPath : path.dirname(d.detectPath), path.dirname(d.configFile)];
2164
+ if (d.mcpConfigFile)
2165
+ rels.push(path.dirname(d.mcpConfigFile));
2166
+ for (const rel of rels) {
2167
+ const dangling = danglingFolderOnWay(workspaceRoot, rel);
2168
+ if (dangling)
2169
+ return dangling;
2170
+ }
2171
+ return undefined;
2172
+ }
2173
+ /** The words for a folder danglingAgentFolder found: the extension's setup report's, word for word. ASCII only. */
2174
+ export function danglingFolderLine(folder) {
2175
+ return `${folder} is a link to a folder that does not exist - left untouched`;
2176
+ }
2177
+ /** Say at the end of setup (takeUnreachableTargets) that an agent was skipped over a link to nothing. */
2178
+ function noteDanglingAgentFolder(folder) {
2179
+ const msg = danglingFolderLine(folder);
2180
+ if (!unreachableTargets.includes(msg))
2181
+ unreachableTargets.push(msg);
2182
+ }
2183
+ /** The words for an agent whose setup threw part way: the extension's, word for word. */
2184
+ export function agentSetupStopped(agent, err) {
2185
+ return `${agent} setup stopped - ${err instanceof Error ? err.message : String(err)}`;
2186
+ }
2187
+ /** Say at the end of setup (takeUnreachableTargets) that one agent's setup threw part way; the others went on. */
2188
+ function noteAgentSetupStopped(agent, err) {
2189
+ const msg = agentSetupStopped(agent, err);
2190
+ if (!unreachableTargets.includes(msg))
2191
+ unreachableTargets.push(msg);
2192
+ }
1772
2193
  /**
1773
2194
  * Remove a vexp section from a file we no longer write to, and delete the file
1774
2195
  * when nothing of the user's is left in it.
@@ -1779,7 +2200,7 @@ function warnUnparseable(filePath) {
1779
2200
  * Leaving the old file behind would put a stale, unread copy of our
1780
2201
  * instructions next to the live one.
1781
2202
  */
1782
- export function removeVexpSection(filePath) {
2203
+ export function removeVexpSection(filePath, agent) {
1783
2204
  if (!fs.existsSync(filePath))
1784
2205
  return "absent";
1785
2206
  let existing;
@@ -1793,6 +2214,10 @@ export function removeVexpSection(filePath) {
1793
2214
  const endIdx = existing.indexOf(VEXP_MARKER_END);
1794
2215
  if (!marker || endIdx === -1)
1795
2216
  return "absent";
2217
+ // A cleanup for `agent` leaves a file committed to the repository alone
2218
+ // (committedCleanupBlocked): nothing was removed.
2219
+ if (agent !== undefined && committedCleanupBlocked([filePath], agent))
2220
+ return "absent";
1796
2221
  const markerIdx = existing.indexOf(marker[0]);
1797
2222
  let startIdx = markerIdx;
1798
2223
  while (startIdx > 0 && existing[startIdx - 1] !== "\n")
@@ -1840,7 +2265,7 @@ export function cursorRulesTarget(workspaceRoot) {
1840
2265
  /* absent, or a directory */
1841
2266
  }
1842
2267
  if (isFile) {
1843
- removeVexpSection(legacy);
2268
+ removeVexpSection(legacy, "Cursor");
1844
2269
  if (fs.existsSync(legacy))
1845
2270
  return ".cursorrules";
1846
2271
  }
@@ -1863,12 +2288,26 @@ export function appendOrCreate(filePath, content, version) {
1863
2288
  fs.writeFileSync(filePath, content, "utf-8");
1864
2289
  return "created";
1865
2290
  }
1866
- let existing = fs.readFileSync(filePath, "utf-8");
2291
+ const original = fs.readFileSync(filePath, "utf-8");
2292
+ let existing = original;
2293
+ // A BOM the file starts with stays in front of it (Visual Studio with
2294
+ // charset = utf-8-bom, Windows PowerShell 5.1's Set-Content -Encoding
2295
+ // UTF8). A section on the first line, or the front matter above it, took
2296
+ // the BOM along in the splice, so a rewrite dropped the user's encoding
2297
+ // mark and a committed file showed one more changed line (review,
2298
+ // 2026-11-08). vexp-core's wizard splices after it and always kept it.
2299
+ // Lockstep with the extension.
2300
+ const bom = original.startsWith("") ? "" : "";
2301
+ const keepBom = (text) => (bom && !text.startsWith(bom) ? bom + text : text);
1867
2302
  // Front matter has to stay the FIRST thing in the file, and the splice below
1868
2303
  // preserves whatever sits above our marker — so a rewrite would leave the
1869
2304
  // old block above and insert a second one. One rule file, one front matter.
2305
+ // A BOM or CRLF line ends (a Windows checkout with core.autocrlf, an
2306
+ // editor that saves CRLF) hid the block from an LF-only match, so every
2307
+ // run added one more (review, 2026-11-08). A BOM in front of the block
2308
+ // goes back in front of the file (keepBom). The template itself is LF.
1870
2309
  if (content.startsWith("---\n")) {
1871
- const lead = existing.match(/^---\n[\s\S]*?\n---\n/);
2310
+ const lead = existing.match(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n/);
1872
2311
  if (lead) {
1873
2312
  existing = existing.slice(lead[0].length).replace(/^\s*\n/, "");
1874
2313
  }
@@ -1888,19 +2327,79 @@ export function appendOrCreate(filePath, content, version) {
1888
2327
  }
1889
2328
  const endIdx = existing.indexOf(VEXP_MARKER_END);
1890
2329
  if (endIdx !== -1) {
1891
- const existingSection = existing.substring(startIdx, endIdx + VEXP_MARKER_END.length);
1892
- if (existingSection === content)
1893
- return "skipped"; // truly identical -> no write
1894
2330
  const before = existing.substring(0, startIdx);
1895
2331
  const after = existing.substring(endIdx + VEXP_MARKER_END.length);
1896
- fs.writeFileSync(filePath, before + content + after, "utf-8");
2332
+ const next = keepBom(before + content + after);
2333
+ // The same bytes: nothing to write, and not "updated". A rule file with
2334
+ // front matter (Cursor .mdc, Windsurf) never matched a section check,
2335
+ // since the template starts with the front matter and the section with
2336
+ // the marker line, so every run at the same version rewrote it and said
2337
+ // "(updated)", while the same committed file at another version (M1)
2338
+ // said "(up to date)" (review, 2026-11-08). Lockstep with the extension.
2339
+ if (next === original)
2340
+ return "skipped";
2341
+ if (committedMarkerOnly(filePath, original, next))
2342
+ return "skipped";
2343
+ fs.writeFileSync(filePath, next, "utf-8");
1897
2344
  }
1898
2345
  else {
1899
- const before = existing.substring(0, startIdx);
1900
- fs.writeFileSync(filePath, before + content, "utf-8");
2346
+ const next = keepBom(existing.substring(0, startIdx) + content);
2347
+ if (committedMarkerOnly(filePath, original, next))
2348
+ return "skipped";
2349
+ fs.writeFileSync(filePath, next, "utf-8");
1901
2350
  }
1902
2351
  return "updated";
1903
2352
  }
2353
+ /**
2354
+ * `text` as git compares it in a checkout with core.autocrlf or eol=crlf:
2355
+ * no leading BOM, LF line ends. The section vexp writes is LF, so in such a
2356
+ * checkout a committed file read as CRLF differed from it in every line,
2357
+ * M1 never applied, and each update moved the marker all the same
2358
+ * (review, 2026-11-08). Only compared, never written. Lockstep with the
2359
+ * extension and vexp-core (as_committed).
2360
+ */
2361
+ function asCommitted(text) {
2362
+ return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
2363
+ }
2364
+ /** `text` with the version taken out of every vexp marker, as committed (asCommitted): two texts equal under it differ in nothing but the versions their markers name. */
2365
+ function withoutMarkerVersions(text) {
2366
+ return asCommitted(text).replace(/<!-- vexp(?:\s+v[\d.]+)? -->/g, "<!-- vexp -->");
2367
+ }
2368
+ /**
2369
+ * M1: is rewriting the instruction file `file` from `original` to `next`
2370
+ * a change of the version in vexp's marker and nothing else, in a file git
2371
+ * tracks? Then the file is left as it is and said once (committedMarkerOnlyLine).
2372
+ * Every update moved `<!-- vexp v... -->` in the committed CLAUDE.md,
2373
+ * AGENTS.md and copilot-instructions.md, so every checkout showed them
2374
+ * modified after an update, and teammates on different versions flipped
2375
+ * them back and forth in their commits (field report, 2026-11-07). Any
2376
+ * other version, older or newer, counts: the text the agents read is the
2377
+ * same. git runs last, only for such a file, so a normal project runs none;
2378
+ * an untracked file is rewritten as before. Lockstep with the extension.
2379
+ */
2380
+ function committedMarkerOnly(file, original, next) {
2381
+ if (next === original || withoutMarkerVersions(next) !== withoutMarkerVersions(original))
2382
+ return false;
2383
+ if (!isGitTracked(file))
2384
+ return false;
2385
+ // The same version, only a BOM or CRLF line ends differ: this vexp's
2386
+ // section is there already, so the file is left with no word ("differs
2387
+ // only in the version marker" would be untrue).
2388
+ if (asCommitted(next) !== asCommitted(original))
2389
+ noteSetup(committedMarkerOnlyLine(file));
2390
+ return true;
2391
+ }
2392
+ /**
2393
+ * Merge vexp's keys into a JSON config (Continue.dev's config.json): an
2394
+ * object key by key, an array by name. A key the payload fills that is
2395
+ * there in another shape (Continue's mcpServers as an object, a string,
2396
+ * null) leaves the whole file byte for byte and says so (leftUntouched):
2397
+ * vexp's array replaced it, the user's servers were gone, and setup said
2398
+ * "updated". An element whose name is one of vexp's own is replaced where
2399
+ * it stands, and copies of it after the first go: every run appended
2400
+ * another vexp server and another vexp-context command, so the file never
2401
+ * settled (review, 2026-11-07). Lockstep with the extension.
2402
+ */
1904
2403
  export function mergeJsonConfig(filePath, mergePayload, version) {
1905
2404
  const read = readJsonConfigSafe(filePath);
1906
2405
  if (!read.ok) {
@@ -1908,6 +2407,11 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
1908
2407
  return "skipped";
1909
2408
  }
1910
2409
  const existing = read.data;
2410
+ const misfit = mergeMisfit(existing, mergePayload);
2411
+ if (misfit) {
2412
+ leftUntouched(filePath, misfit.key, misfit.shape);
2413
+ return "skipped";
2414
+ }
1911
2415
  // Build the merged result first, then compare to avoid unnecessary writes.
1912
2416
  const merged = { ...existing };
1913
2417
  for (const [key, value] of Object.entries(mergePayload)) {
@@ -1918,7 +2422,7 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
1918
2422
  };
1919
2423
  }
1920
2424
  else if (Array.isArray(value) && Array.isArray(merged[key])) {
1921
- merged[key] = [...merged[key], ...value];
2425
+ merged[key] = mergeNamed(merged[key], value);
1922
2426
  }
1923
2427
  else {
1924
2428
  merged[key] = value;
@@ -1931,37 +2435,79 @@ export function mergeJsonConfig(filePath, mergePayload, version) {
1931
2435
  const existingStr = JSON.stringify(existing, null, 2);
1932
2436
  if (mergedStr === existingStr)
1933
2437
  return "skipped";
2438
+ if (committedSentinelOnly(filePath, existing, merged))
2439
+ return "skipped";
1934
2440
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1935
2441
  if (read.existed)
1936
2442
  backupConfig(filePath);
1937
2443
  fs.writeFileSync(filePath, mergedStr, "utf-8");
1938
2444
  return "merged";
1939
2445
  }
1940
- // ---------------------------------------------------------------------------
1941
- // MCP config writers - adapted for Rust binary path
1942
- // ---------------------------------------------------------------------------
1943
- /**
1944
- * Write MCP config JSON for agents that use mcpServers format (Cursor, Windsurf).
1945
- * Returns true if a new entry was written.
1946
- */
1947
2446
  /**
1948
- * Antigravity reads MCP servers from a MACHINE-GLOBAL file:
1949
- * `~/.gemini/antigravity/mcp_config.json` (same schema as the workspace
1950
- * mcp.json files: top-level `mcpServers`, stdio `command`/`args`/`env`;
1951
- * note Antigravity uses `serverUrl` — not `url` — for HTTP servers, which
1952
- * is why we register the stdio transport here).
1953
- *
1954
- * Deliberately NO `VEXP_WORKSPACE` env pin: the file is global, and pinning
1955
- * one workspace breaks every other project (the 2.0.32 multi-session
1956
- * lesson). `vexp-core mcp` resolves the workspace from its spawn cwd.
2447
+ * M1 for Continue's config.json: is the merge a change of `__vexp_version`
2448
+ * and nothing else, in a file git tracks? Then the file is left as it is,
2449
+ * said once in M1's words (committedMarkerOnlyLine): the sentinel is the
2450
+ * marker of a JSON file, and a team that commits .continue/config.json saw
2451
+ * it modified after every update, as with the Markdown files (review,
2452
+ * 2026-11-08). The spread keeps the key where it stands (merged starts as
2453
+ * a copy of existing). git runs last, only for such a merge, so a normal
2454
+ * project runs none; an untracked file is written as before. Lockstep
2455
+ * with the extension.
1957
2456
  */
1958
- /** Cascade's one and only MCP config, on the side of the boundary vexp runs on. */
1959
- export function windsurfGlobalMcpPath() {
1960
- return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
2457
+ function committedSentinelOnly(file, existing, merged) {
2458
+ if (typeof existing.__vexp_version !== "string")
2459
+ return false;
2460
+ if (JSON.stringify({ ...merged, __vexp_version: existing.__vexp_version }, null, 2) !== JSON.stringify(existing, null, 2))
2461
+ return false;
2462
+ if (!isGitTracked(file))
2463
+ return false;
2464
+ noteSetup(committedMarkerOnlyLine(file));
2465
+ return true;
1961
2466
  }
1962
- /**
1963
- * Devin Desktop Next — the beta channel, formerly Windsurf Next — is a separate
1964
- * install with its own Cascade config under `~/.codeium/windsurf-next/`
2467
+ /** The first key of `payload` that `existing` holds in another shape: an array where vexp merges an array, a non-object where an object. */
2468
+ function mergeMisfit(existing, payload) {
2469
+ for (const [key, value] of Object.entries(payload)) {
2470
+ if (!Object.prototype.hasOwnProperty.call(existing, key))
2471
+ continue;
2472
+ if (Array.isArray(value) && !Array.isArray(existing[key]))
2473
+ return { key, shape: "an array" };
2474
+ if (asPlainObject(value) && !asPlainObject(existing[key]))
2475
+ return { key, shape: "an object" };
2476
+ }
2477
+ return undefined;
2478
+ }
2479
+ /** `ours` merged into `theirs` by name: each of ours replaces the first element of that name, later ones of it go, and the rest are appended. */
2480
+ function mergeNamed(theirs, ours) {
2481
+ const nameOf = (v) => {
2482
+ const n = asPlainObject(v) ? v.name : undefined;
2483
+ return typeof n === "string" ? n : undefined;
2484
+ };
2485
+ const out = [...theirs];
2486
+ const tail = [];
2487
+ for (const el of ours) {
2488
+ const name = nameOf(el);
2489
+ const at = name === undefined ? -1 : out.findIndex((x) => nameOf(x) === name);
2490
+ if (at < 0) {
2491
+ tail.push(el);
2492
+ continue;
2493
+ }
2494
+ out[at] = el;
2495
+ for (let k = out.length - 1; k > at; k--)
2496
+ if (nameOf(out[k]) === name)
2497
+ out.splice(k, 1);
2498
+ }
2499
+ return [...out, ...tail];
2500
+ }
2501
+ // ---------------------------------------------------------------------------
2502
+ // MCP config writers - adapted for Rust binary path
2503
+ // ---------------------------------------------------------------------------
2504
+ /** Cascade's one and only MCP config, on the side of the boundary vexp runs on. */
2505
+ export function windsurfGlobalMcpPath() {
2506
+ return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
2507
+ }
2508
+ /**
2509
+ * Devin Desktop Next — the beta channel, formerly Windsurf Next — is a separate
2510
+ * install with its own Cascade config under `~/.codeium/windsurf-next/`
1965
2511
  * (field report, 2026-09-01: a user ran the stable build on one project and
1966
2512
  * Next on another; the entry written for stable was invisible to Next). We
1967
2513
  * write it only when that directory exists: it is the build's own footprint,
@@ -1976,7 +2522,8 @@ export function windsurfNextInstalled() {
1976
2522
  export function configureWindsurfNextGlobal(binaryPath, mcpServerPath, workspaceRoot) {
1977
2523
  if (!windsurfNextInstalled())
1978
2524
  return false;
1979
- return writeMcpConfig(windsurfNextGlobalMcpPath(), binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot);
2525
+ // Machine-wide: written through a dotfile manager's link, never checked against the project.
2526
+ return writeMcpServersFile(windsurfNextGlobalMcpPath(), binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot, "alwaysAllow");
1980
2527
  }
1981
2528
  /**
1982
2529
  * Windsurf (Cascade) reads MCP servers from ~/.codeium/windsurf/mcp_config.json
@@ -1996,109 +2543,1881 @@ export function configureWindsurfGlobal(binaryPath, mcpServerPath, workspaceRoot
1996
2543
  catch {
1997
2544
  return false;
1998
2545
  }
1999
- return writeMcpConfig(p, binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot);
2546
+ // Machine-wide: written through a dotfile manager's link, never checked against the project.
2547
+ return writeMcpServersFile(p, binaryPath, VEXP_TOOLS, mcpServerPath, workspaceRoot, "alwaysAllow");
2000
2548
  }
2001
2549
  /**
2002
2550
  * Drop our entry from the `.windsurf/mcp.json` older versions wrote (a file
2003
2551
  * Cascade never read), deleting the file when nothing of the user's is left.
2552
+ * A file that is a link is never deleted nor written over: vexp's member
2553
+ * goes from its target when that is inside the project, and nothing is
2554
+ * touched when it is not (removeProjectMcpEntryThroughLink).
2004
2555
  */
2005
2556
  export function removeDeadWindsurfMcpJson(workspaceRoot) {
2006
2557
  const p = path.join(workspaceRoot, ".windsurf", "mcp.json");
2558
+ let link = false;
2559
+ try {
2560
+ link = fs.lstatSync(p).isSymbolicLink();
2561
+ }
2562
+ catch {
2563
+ return "absent";
2564
+ }
2565
+ if (link)
2566
+ return removeProjectMcpEntryThroughLink(p, "mcpServers", workspaceRoot, "Windsurf") === "edited" ? "stripped" : "absent";
2007
2567
  const read = readJsonConfigSafe(p);
2008
2568
  if (!read.existed || !read.ok)
2009
2569
  return "absent";
2010
2570
  const servers = read.data.mcpServers;
2011
2571
  if (!servers || !("vexp" in servers))
2012
2572
  return "absent";
2573
+ if (committedCleanupBlocked([p], "Windsurf"))
2574
+ return "absent";
2013
2575
  delete servers["vexp"];
2014
2576
  const otherKeys = Object.keys(read.data).filter((k) => k !== "mcpServers");
2015
2577
  if (Object.keys(servers).length === 0 && otherKeys.length === 0) {
2016
2578
  try {
2017
2579
  fs.unlinkSync(p);
2018
2580
  }
2019
- catch { /* best effort */ }
2020
- return "deleted";
2581
+ catch { /* best effort */ }
2582
+ return "deleted";
2583
+ }
2584
+ try {
2585
+ fs.writeFileSync(p, JSON.stringify(read.data, null, 2), "utf-8");
2586
+ }
2587
+ catch { /* best effort */ }
2588
+ return "stripped";
2589
+ }
2590
+ /**
2591
+ * Take vexp's server out of a project MCP file that is a symlink (a
2592
+ * repository that keeps one MCP file and links it where each tool reads
2593
+ * it): such a file is not vexp's to delete. A removal unlinked the link, or
2594
+ * wrote a regular file over it, and a link to a file outside the project
2595
+ * edited a file of the user's elsewhere. So a link whose real path is
2596
+ * inside `workspaceRoot` loses vexp's member in its target, cut out of the
2597
+ * text when that is not strict JSON (removeJsoncMember), and the link and
2598
+ * the target both stay, even with nothing left in it; a link out of the
2599
+ * project, or to nothing, is left as it is, with one line said. Never a
2600
+ * link removed. Lockstep with the extension's twin.
2601
+ */
2602
+ export function removeProjectMcpEntryThroughLink(file, key, workspaceRoot, agent) {
2603
+ try {
2604
+ let real;
2605
+ try {
2606
+ real = fs.realpathSync(file);
2607
+ }
2608
+ catch {
2609
+ process.stdout.write(` - ${file} is a link to a file that does not exist: left as it is\n`);
2610
+ return undefined;
2611
+ }
2612
+ let rootReal = path.resolve(workspaceRoot);
2613
+ try {
2614
+ rootReal = fs.realpathSync(workspaceRoot);
2615
+ }
2616
+ catch {
2617
+ /* compared as written */
2618
+ }
2619
+ const rel = path.relative(rootReal, real);
2620
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
2621
+ process.stdout.write(` - ${file} is a link to ${real}, outside this project: vexp's entry there is left as it is\n`);
2622
+ return undefined;
2623
+ }
2624
+ const read = readJsonConfigSafe(real);
2625
+ if (!read.ok)
2626
+ return undefined;
2627
+ const servers = read.data[key];
2628
+ if (!asPlainObject(servers) || !isVexpWrittenEntry(servers["vexp"]))
2629
+ return undefined;
2630
+ // The link and its target are both the repository's when git tracks either.
2631
+ if (agent !== undefined && committedCleanupBlocked([file, real], agent))
2632
+ return undefined;
2633
+ const raw = fs.readFileSync(real, "utf-8");
2634
+ let next;
2635
+ try {
2636
+ const data = JSON.parse(raw);
2637
+ delete data[key]["vexp"];
2638
+ if (Object.keys(data[key]).length === 0)
2639
+ delete data[key];
2640
+ next = JSON.stringify(data, null, 2) + "\n";
2641
+ }
2642
+ catch {
2643
+ next = removeJsoncMember(raw, key, "vexp");
2644
+ }
2645
+ if (next === undefined) {
2646
+ process.stdout.write(` - vexp's entry in ${real} (linked from ${file}) could not be cut out of the text: left as it is\n`);
2647
+ return undefined;
2648
+ }
2649
+ backupConfig(file);
2650
+ fs.writeFileSync(real, next, "utf-8");
2651
+ return "edited";
2652
+ }
2653
+ catch {
2654
+ /* best-effort: what could not be removed stays, and is harmless */
2655
+ return undefined;
2656
+ }
2657
+ }
2658
+ /**
2659
+ * True inside WSL. `procVersion` is injectable for tests; when given, it is
2660
+ * the only thing consulted.
2661
+ */
2662
+ export function isWsl(procVersion) {
2663
+ if (procVersion !== undefined)
2664
+ return /microsoft/i.test(procVersion);
2665
+ if (process.platform !== "linux")
2666
+ return false;
2667
+ if (process.env.WSL_DISTRO_NAME)
2668
+ return true;
2669
+ try {
2670
+ return /microsoft/i.test(fs.readFileSync("/proc/version", "utf-8"));
2671
+ }
2672
+ catch {
2673
+ return false;
2674
+ }
2675
+ }
2676
+ function shellQuote(s) {
2677
+ return `'${s.replace(/'/g, `'\\''`)}'`;
2678
+ }
2679
+ /**
2680
+ * The Windows-side Cascade entry for a project that lives in WSL, filled in
2681
+ * with the real paths, ready to paste. Cascade launches MCP servers on the
2682
+ * Windows side even when the workspace is a WSL folder, and reads
2683
+ * %USERPROFILE%\.codeium\windsurf\mcp_config.json there — never the WSL home
2684
+ * `vexp setup` can write to. Environment variables in the entry do not cross
2685
+ * wsl.exe, so VEXP_WORKSPACE is set on the command line inside WSL; `bash -lc`
2686
+ * puts a version-managed node on PATH. We do not write Windows files from WSL
2687
+ * (see the Windows + WSL2 docs) — we hand the user the exact entry instead.
2688
+ */
2689
+ export function windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath, distro = process.env.WSL_DISTRO_NAME ?? "<your distro>") {
2690
+ const inner = mcpServerPath && fs.existsSync(mcpServerPath)
2691
+ ? `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(resolveNodeCommand())} ${shellQuote(mcpServerPath)}`
2692
+ : `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(binaryPath)} mcp`;
2693
+ const entry = {
2694
+ mcpServers: {
2695
+ vexp: {
2696
+ command: "wsl.exe",
2697
+ args: ["-d", distro, "--", "bash", "-lc", inner],
2698
+ alwaysAllow: VEXP_TOOLS,
2699
+ },
2700
+ },
2701
+ };
2702
+ return [
2703
+ "Windsurf on Windows, project in WSL: Cascade starts MCP servers on the WINDOWS side and reads",
2704
+ "%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json there — the entry vexp just wrote in your WSL home is not the one it reads.",
2705
+ "(Devin Desktop Next, the beta build, keeps its own copy: %USERPROFILE%\\.codeium\\windsurf-next\\mcp_config.json — same entry.)",
2706
+ "Paste this on the Windows side (Windsurf: Settings → Cascade → MCP → view raw config), then quit and reopen Windsurf:",
2707
+ JSON.stringify(entry, null, 2),
2708
+ "Devin Local reads .devin/mcp_config.json from the project (written). If it runs on the Windows side as well, the same entry goes in %APPDATA%\\devin\\mcp_config.json.",
2709
+ "Docs: https://vexp.dev/docs#windows-wsl",
2710
+ ].join("\n");
2711
+ }
2712
+ // ---------------------------------------------------------------------------
2713
+ // Antigravity (Google). Lockstep with packages/vexp-vscode/src/providers/
2714
+ // agent-auto-config.ts (configureAntigravityProject,
2715
+ // removeDeadAntigravityEntries, isDeadAntigravityEntry).
2716
+ //
2717
+ // Every current Antigravity build (App 2.x, IDE 2.6, the agy CLI and the
2718
+ // VS Code extension) reads MCP servers from ~/.gemini/config/mcp_config.json
2719
+ // and from <workspace>/.agents/mcp_config.json. The 1.x file vexp used to
2720
+ // write, ~/.gemini/antigravity/mcp_config.json, is read by none of them, and
2721
+ // its entry named no project. Measured with agy 1.5.3 (2026-11-07): an MCP
2722
+ // server starts in the agy process's own folder (the window's first folder
2723
+ // in VS Code, often the home folder in the Antigravity app) and gets no
2724
+ // workspace variable, so `vexp-core mcp` with no pin serves whatever folder
2725
+ // that is, and from the home folder an empty tool list (field report,
2726
+ // 2026-11-07).
2727
+ //
2728
+ // So the entry goes in the project file, pinned with VEXP_WORKSPACE. The
2729
+ // files take no ${VAR} expansion, so the pin is the literal path. Nothing
2730
+ // is written machine-wide: a pinned global entry serves one project (the
2731
+ // 2.0.32 multi-session rule), an unpinned one serves the wrong folder, and
2732
+ // a global `vexp` starts with Antigravity, before any conversation, so it
2733
+ // keeps the name and the project's entry, loaded per conversation, is not
2734
+ // used. A file that holds only vexp's entry is kept out of git
2735
+ // (excludeFromGit).
2736
+ // ---------------------------------------------------------------------------
2737
+ /** Antigravity's project MCP file, relative to the project root. */
2738
+ export const ANTIGRAVITY_PROJECT_MCP = ".agents/mcp_config.json";
2739
+ /** Keys of vexp's entry the user may set and Antigravity reads: a rewrite keeps them. */
2740
+ export const ANTIGRAVITY_USER_KEYS = ["disabled", "disabledTools", "enabledTools", "tools", "timeoutSeconds"];
2741
+ export function antigravityProjectMcpPath(workspaceRoot) {
2742
+ return path.join(workspaceRoot, ".agents", "mcp_config.json");
2743
+ }
2744
+ /**
2745
+ * Register vexp with Antigravity for this project: `mcpServers.vexp` in
2746
+ * <root>/.agents/mcp_config.json = the binary, `mcp`, and
2747
+ * env.VEXP_WORKSPACE = the root. The binary and never `node`: Antigravity
2748
+ * is a GUI app, and a dock-launched app has no shell PATH (the Kiro lesson).
2749
+ * The file is read as JSONC (comments, trailing commas, a BOM); one that
2750
+ * will not parse is left untouched and reported (takeSkippedConfigs), and
2751
+ * so is one that is not UTF-8 text (takeUnreachableTargets). Only
2752
+ * `mcpServers.vexp` is vexp's: the project's other servers stay, a
2753
+ * `vexp-dev` of the user's among them (no vexp build ever wrote a legacy
2754
+ * `vexp-*` key here), and so do the keys of ANTIGRAVITY_USER_KEYS the user
2755
+ * set inside vexp's entry, and the pin's spelling when it names this
2756
+ * folder already (antigravityPin). An entry of another vexp install that
2757
+ * works and is pinned here is kept (adoptableEntry). A file whose
2758
+ * mcpServers is not an object, or that cannot be written, is left alone
2759
+ * and reported (takeUnreachableTargets), never thrown: `vexp setup` goes
2760
+ * on with the other agents, as the extension and vexp-core do. A file that
2761
+ * leads out of the project through a link, or to nothing, is left as it is
2762
+ * and reported (projectWriteLanding); one that lands on Antigravity's
2763
+ * machine-wide files is refused in its own words first
2764
+ * (antigravityProjectFileBlocked). ZCode's .agents/mcp.json is another file
2765
+ * and is never touched here. Same bytes as the extension's writer. Returns
2766
+ * true when the file was written.
2767
+ */
2768
+ export function configureAntigravityProject(workspaceRoot, binaryPath) {
2769
+ const target = antigravityProjectMcpPath(workspaceRoot);
2770
+ const refuse = (reason) => {
2771
+ const msg = `${target} NOT written: it ${reason}`;
2772
+ if (!unreachableTargets.includes(msg))
2773
+ unreachableTargets.push(msg);
2774
+ return false;
2775
+ };
2776
+ const blocked = antigravityProjectFileBlocked(workspaceRoot);
2777
+ if (blocked)
2778
+ return refuse(blocked);
2779
+ if (projectWriteRefused(target, workspaceRoot))
2780
+ return false;
2781
+ const read = readAntigravityProjectFile(target);
2782
+ if (read.notUtf8)
2783
+ return refuse(ANTIGRAVITY_NOT_UTF8);
2784
+ if (!read.ok) {
2785
+ warnUnparseable(target);
2786
+ return false;
2787
+ }
2788
+ const cfg = read.data;
2789
+ if (antigravityServersMisshapen(cfg))
2790
+ return refuse(ANTIGRAVITY_SERVERS_NOT_AN_OBJECT);
2791
+ // The file holds this machine's paths (the binary, this folder), and each
2792
+ // machine's vexp rewrites it with its own. A file that holds only vexp's
2793
+ // entry is vexp's and is never meant for git: it goes into the
2794
+ // repository's own exclude file once the file is settled (written,
2795
+ // already identical, adopted), whether this write created it or found it.
2796
+ // Found matters: the doctor tells a user whose file is committed to run
2797
+ // `git rm --cached`, which leaves it untracked and not ignored, and the
2798
+ // next `git add -A` committed it again. One with other servers or keys is
2799
+ // the project's business (it may be committed on purpose: the doctor says
2800
+ // what that costs), and no git command runs for it, so a start stays
2801
+ // cheap. excludeFromGit's own guards (no repository, no git, ignored
2802
+ // already, tracked, the line there) still decide.
2803
+ const settled = (wrote) => {
2804
+ if (!read.existed || holdsOnlyVexp(cfg))
2805
+ excludeFromGit(workspaceRoot, ANTIGRAVITY_PROJECT_MCP);
2806
+ return wrote;
2807
+ };
2808
+ const targetArgs = ["mcp"];
2809
+ const prev = cfg.mcpServers?.["vexp"];
2810
+ // Committed and pinned by another checkout: see committedPin. A file that
2811
+ // holds this machine's paths is not meant for git (the doctor says how to
2812
+ // take it out), but while it is committed a setup in a worktree must not
2813
+ // repin it either.
2814
+ const committed = committedPin(target, workspaceRoot, envPin(prev?.env));
2815
+ if (committed) {
2816
+ if (!staleVexpInstall(prev))
2817
+ return settled(noteCommittedPin(target, committed));
2818
+ cfg.mcpServers["vexp"] = { ...prev, command: binaryPath, args: targetArgs };
2819
+ try {
2820
+ backupConfig(target);
2821
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
2822
+ }
2823
+ catch (e) {
2824
+ return refuse(`could not be written (${e instanceof Error ? e.message : String(e)})`);
2825
+ }
2826
+ return settled(true);
2827
+ }
2828
+ const targetEnv = { VEXP_WORKSPACE: antigravityPin(prev, workspaceRoot) };
2829
+ const envMatches = JSON.stringify(prev?.env) === JSON.stringify(targetEnv);
2830
+ const identical = prev?.command === binaryPath &&
2831
+ Array.isArray(prev?.args) &&
2832
+ JSON.stringify(prev?.args) === JSON.stringify(targetArgs) &&
2833
+ envMatches;
2834
+ if (identical)
2835
+ return settled(false);
2836
+ // Another vexp install already left a working entry here: don't fight it.
2837
+ // The VEXP_WORKSPACE pin still gates, so a moved project is repinned.
2838
+ if (envMatches && adoptableEntry(prev))
2839
+ return settled(false);
2840
+ const servers = cfg.mcpServers ?? {};
2841
+ const kept = {};
2842
+ if (prev && typeof prev === "object") {
2843
+ for (const k of ANTIGRAVITY_USER_KEYS)
2844
+ if (k in prev)
2845
+ kept[k] = prev[k];
2846
+ }
2847
+ servers["vexp"] = { command: binaryPath, args: targetArgs, env: targetEnv, ...kept };
2848
+ cfg.mcpServers = servers;
2849
+ try {
2850
+ fs.mkdirSync(path.dirname(target), { recursive: true });
2851
+ // An empty file, or one of only a BOM and whitespace, holds nothing of
2852
+ // the user's: no backup of it, as in vexp-core.
2853
+ if (read.existed && !read.blank)
2854
+ backupConfig(target);
2855
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
2856
+ }
2857
+ catch (e) {
2858
+ return refuse(`could not be written (${e instanceof Error ? e.message : String(e)})`);
2859
+ }
2860
+ return settled(true);
2861
+ }
2862
+ /**
2863
+ * Does the project file hold nothing but vexp's server: no top-level key
2864
+ * but `mcpServers`, and no server in it but `vexp`? Such a file is vexp's
2865
+ * (settled in configureAntigravityProject) and is kept out of git; the
2866
+ * doctor advises `git rm --cached` only for such a file (doctor.ts,
2867
+ * agOnlyVexp). Lockstep with the extension and vexp-core.
2868
+ */
2869
+ export function holdsOnlyVexp(cfg) {
2870
+ if (!Object.keys(cfg).every((k) => k === "mcpServers"))
2871
+ return false;
2872
+ const servers = cfg.mcpServers;
2873
+ return asPlainObject(servers) && Object.keys(servers).every((k) => k === "vexp");
2874
+ }
2875
+ /** Why a project file whose mcpServers is not an object is left alone. Lockstep with the extension and vexp-core. */
2876
+ export const ANTIGRAVITY_SERVERS_NOT_AN_OBJECT = "has an mcpServers that is not an object - left untouched";
2877
+ /** Why a project file that is not UTF-8 text is left alone. Lockstep with the extension and vexp-core. */
2878
+ export const ANTIGRAVITY_NOT_UTF8 = "is not UTF-8 text - left untouched";
2879
+ /**
2880
+ * The project file as the Antigravity writers read it: readJsonConfigSafe's
2881
+ * verdict, but bytes that are not UTF-8 (an ANSI file PowerShell 5.1's
2882
+ * Set-Content writes) are `notUtf8` and never rewritten, as in vexp-core.
2883
+ * Decoded with replacement characters, such a file parsed, and the rewrite
2884
+ * saved another server's `jos\xe9` as `jos\ufffd` (review, 2026-11-07).
2885
+ * `blank` is a file with nothing of the user's in it: absent, empty, or a
2886
+ * BOM and whitespace. Lockstep with the extension.
2887
+ */
2888
+ export function readAntigravityProjectFile(target) {
2889
+ if (!fs.existsSync(target))
2890
+ return { data: {}, ok: true, existed: false, blank: true };
2891
+ let raw;
2892
+ try {
2893
+ const bytes = fs.readFileSync(target);
2894
+ try {
2895
+ raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
2896
+ }
2897
+ catch {
2898
+ return { data: {}, ok: false, existed: true, blank: false, notUtf8: true };
2899
+ }
2900
+ }
2901
+ catch {
2902
+ return { data: {}, ok: false, existed: true, blank: false };
2903
+ }
2904
+ if (raw.replace(/^\uFEFF/, "").trim() === "")
2905
+ return { data: {}, ok: true, existed: true, blank: true };
2906
+ let parsed;
2907
+ try {
2908
+ parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
2909
+ }
2910
+ catch {
2911
+ try {
2912
+ parsed = parseJsonc(raw);
2913
+ }
2914
+ catch {
2915
+ return { data: {}, ok: false, existed: true, blank: false };
2916
+ }
2917
+ }
2918
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2919
+ return { data: parsed, ok: true, existed: true, blank: false };
2920
+ }
2921
+ return { data: {}, ok: false, existed: true, blank: false };
2922
+ }
2923
+ /** An mcpServers that is there and is not an object (containerMisshapen). vexp-core's writer skips the same files. */
2924
+ function antigravityServersMisshapen(data) {
2925
+ return containerMisshapen(data, "mcpServers");
2926
+ }
2927
+ /**
2928
+ * Do two absolute spellings name one folder: the same path, the same real
2929
+ * path behind a link, or the same Windows path in another letter case? A
2930
+ * relative pin, or one that does not resolve, is only itself.
2931
+ */
2932
+ function sameFolder(a, b) {
2933
+ if (a === b)
2934
+ return true;
2935
+ if (!path.isAbsolute(a) || !path.isAbsolute(b))
2936
+ return false;
2937
+ if (process.platform === "win32" && path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase())
2938
+ return true;
2939
+ try {
2940
+ return fs.realpathSync(a) === fs.realpathSync(b);
2941
+ }
2942
+ catch {
2943
+ return false;
2944
+ }
2945
+ }
2946
+ /**
2947
+ * The VEXP_WORKSPACE to write: the root, or the pin vexp's entry already
2948
+ * has when that names the same folder in another spelling. The CLI pins
2949
+ * the shell's path, the extension VS Code's (a lower-case drive letter on
2950
+ * Windows), vexp-core its canonical one, and a project opened through a
2951
+ * link has two; compared as text, each writer rewrote the others' entry,
2952
+ * with a backup, at its next run. Lockstep with the extension and vexp-core.
2953
+ */
2954
+ export function antigravityPin(prev, workspaceRoot) {
2955
+ const env = prev && typeof prev === "object" ? prev.env : undefined;
2956
+ const pin = env && typeof env === "object" ? env.VEXP_WORKSPACE : undefined;
2957
+ return typeof pin === "string" && sameFolder(pin, workspaceRoot) ? pin : workspaceRoot;
2958
+ }
2959
+ /**
2960
+ * Why the project file must not be written, when it must not: `.agents` is
2961
+ * a file, or a link to nothing; the file is a link to nothing (a write
2962
+ * would create its target, wherever that is); or the file, by a link of its
2963
+ * own or through a linked `.agents` folder, is one of Antigravity's
2964
+ * machine-wide files, and writing it would put a pinned entry there for
2965
+ * every project. Lockstep with the extension (and with vexp-core's
2966
+ * register_antigravity_project).
2967
+ */
2968
+ export function antigravityProjectFileBlocked(workspaceRoot) {
2969
+ const dir = path.join(workspaceRoot, ".agents");
2970
+ let dirReal;
2971
+ try {
2972
+ if (!fs.statSync(dir).isDirectory())
2973
+ return `cannot be created - ${dir} is a file, not a folder`;
2974
+ dirReal = fs.realpathSync(dir);
2975
+ }
2976
+ catch {
2977
+ try {
2978
+ if (fs.lstatSync(dir).isSymbolicLink())
2979
+ return `cannot be created - ${dir} is a link to a folder that does not exist`;
2980
+ }
2981
+ catch {
2982
+ /* no folder yet: created on write */
2983
+ }
2984
+ return undefined;
2985
+ }
2986
+ const target = antigravityProjectMcpPath(workspaceRoot);
2987
+ let link = false;
2988
+ try {
2989
+ link = fs.lstatSync(target).isSymbolicLink();
2990
+ }
2991
+ catch {
2992
+ /* absent: written in the folder's real place */
2993
+ }
2994
+ let lands = path.join(dirReal, path.basename(target));
2995
+ if (link) {
2996
+ try {
2997
+ lands = fs.realpathSync(target);
2998
+ }
2999
+ catch {
3000
+ return "is a link to a file that does not exist";
3001
+ }
3002
+ }
3003
+ if (!antigravityMachineMcpLocations().includes(lands))
3004
+ return undefined;
3005
+ return link
3006
+ ? "is a link to Antigravity's machine-wide mcp_config.json - vexp registers per project only"
3007
+ : "is in a folder linked to Antigravity's machine-wide config folder - vexp registers per project only";
3008
+ }
3009
+ /**
3010
+ * An MCP entry vexp wrote: the core binary or the bundled mcp-server.cjs,
3011
+ * named by its path. A bare `vexp-core` looked up on PATH is an entry
3012
+ * someone wrote by hand. Lockstep with the extension's isVexpWrittenEntry.
3013
+ */
3014
+ function isVexpWrittenEntry(entry) {
3015
+ const cmd = entryCommand(entry);
3016
+ if (!cmd)
3017
+ return false;
3018
+ const base = (cmd.split(/[\\/]/).pop() ?? "").toLowerCase();
3019
+ if (base.startsWith("vexp-core"))
3020
+ return /[\\/]/.test(cmd);
3021
+ const args = entry.args;
3022
+ return Array.isArray(args) && args.some((a) => typeof a === "string" && /vexp/i.test(a) && /mcp-server\.cjs$/.test(a));
3023
+ }
3024
+ /**
3025
+ * Does an entry run what a vexp install put on disk: a file in an extension
3026
+ * folder (vexp.vexp-vscode-<ver>), the npm package's binary
3027
+ * (@vexp/core-<platform>), or a bundled mcp-server.cjs? A vexp-core built
3028
+ * or copied anywhere else is the user's own. Lockstep with the extension.
3029
+ */
3030
+ function namesVexpInstall(entry) {
3031
+ if (vexpInstallVersion(entry) !== undefined)
3032
+ return true;
3033
+ if (/[\\/]@vexp[\\/]core-[^\\/]+[\\/]/.test(entryCommand(entry) ?? ""))
3034
+ return true;
3035
+ const args = entry.args;
3036
+ return Array.isArray(args) && args.some((a) => typeof a === "string" && /vexp/i.test(a) && /mcp-server\.cjs$/.test(a));
3037
+ }
3038
+ /**
3039
+ * A machine-wide Antigravity entry that vexp wrote and that names no
3040
+ * project: none of what vexp-core takes a project from (`--workspace`, in
3041
+ * either spelling clap accepts, env VEXP_WORKSPACE or CLAUDE_PROJECT_DIR,
3042
+ * a `cwd`). Such an entry serves whatever folder Antigravity starts it from
3043
+ * (the agy process's own: the window's first folder in VS Code, often the
3044
+ * home folder in the Antigravity app, where it lists no tools), and as it
3045
+ * starts with Antigravity, before any conversation, it keeps the name and
3046
+ * the project's pinned entry is not used (measured with agy 1.5.3,
3047
+ * 2026-11-07). A pinned entry or one the user made is never this. Lockstep
3048
+ * with the extension.
3049
+ */
3050
+ export function isDeadAntigravityEntry(entry) {
3051
+ if (!isVexpWrittenEntry(entry) || !namesVexpInstall(entry))
3052
+ return false;
3053
+ const e = entry;
3054
+ const env = e.env;
3055
+ if (env && typeof env === "object" && (typeof env.VEXP_WORKSPACE === "string" || typeof env.CLAUDE_PROJECT_DIR === "string"))
3056
+ return false;
3057
+ if (Array.isArray(e.args) && e.args.some((a) => a === "--workspace" || (typeof a === "string" && a.startsWith("--workspace="))))
3058
+ return false;
3059
+ return !(typeof e.cwd === "string" && e.cwd.trim() !== "");
3060
+ }
3061
+ /** The 1.x file and today's global one, as named under the home folder. */
3062
+ function antigravityMachineMcpNames() {
3063
+ return [antigravityLegacyMcpPath(os.homedir()), antigravityGlobalMcpPath(os.homedir())];
3064
+ }
3065
+ export function antigravityLegacyMcpPath(home) {
3066
+ return path.join(home, ".gemini", "antigravity", "mcp_config.json");
3067
+ }
3068
+ export function antigravityGlobalMcpPath(home) {
3069
+ return path.join(home, ".gemini", "config", "mcp_config.json");
3070
+ }
3071
+ /**
3072
+ * The machine-wide files vexp may take its own dead entry out of: the 1.x
3073
+ * file when it is a regular file, or the file the 1.x name links to (agy's
3074
+ * migration link). Nothing else, and nothing that does not exist. Lockstep
3075
+ * with the extension.
3076
+ */
3077
+ export function antigravityFilesVexpCleans(home) {
3078
+ const legacy = antigravityLegacyMcpPath(home);
3079
+ let st;
3080
+ try {
3081
+ st = fs.lstatSync(legacy);
3082
+ }
3083
+ catch {
3084
+ return [];
3085
+ }
3086
+ try {
3087
+ if (st.isFile())
3088
+ return [{ file: fs.realpathSync(legacy), viaLink: false }];
3089
+ if (!st.isSymbolicLink())
3090
+ return [];
3091
+ const real = fs.realpathSync(legacy);
3092
+ return fs.statSync(real).isFile() ? [{ file: real, viaLink: true }] : [];
3093
+ }
3094
+ catch {
3095
+ return []; // a link to nothing
3096
+ }
3097
+ }
3098
+ /** Does `file` name the same file as one vexp may clean (antigravityFilesVexpCleans)? */
3099
+ export function vexpCleansAntigravityFile(home, file) {
3100
+ let real;
3101
+ try {
3102
+ real = fs.realpathSync(file);
3103
+ }
3104
+ catch {
3105
+ return false;
3106
+ }
3107
+ return antigravityFilesVexpCleans(home).some((c) => c.file === real);
3108
+ }
3109
+ // ---------------------------------------------------------------------------
3110
+ // Keeping the project file out of git. <root>/.agents/mcp_config.json holds
3111
+ // the absolute path of this machine's vexp binary and of this folder, and
3112
+ // every machine's vexp rewrites it with its own: committed, it moves between
3113
+ // machines as a diff nobody wants. When a setup creates it, or settles one
3114
+ // that holds only vexp's entry, its anchored path goes into the
3115
+ // repository's own exclude file, never into .gitignore.
3116
+ // Lockstep with the extension (antigravity-files.ts) and vexp-core
3117
+ // (agent_mcp.rs): the same two lines.
3118
+ // ---------------------------------------------------------------------------
3119
+ /** The comment vexp writes above the project file's line in the repository's exclude file. Lockstep with the extension and vexp-core. */
3120
+ export const ANTIGRAVITY_EXCLUDE_COMMENT = "# vexp: Antigravity MCP entry with this machine's paths";
3121
+ /** How long one git command may take: a setup or a doctor never waits on git for longer. */
3122
+ const GIT_TIMEOUT_MS = 3000;
3123
+ /**
3124
+ * The variables a git hook, `git --git-dir=... <alias>` or a dotfile
3125
+ * manager's shell hands down, which would make git answer for another
3126
+ * repository than the folder's. Lockstep with the extension and vexp-core
3127
+ * (agent_mcp.rs, GIT_REPO_VARS).
3128
+ */
3129
+ const GIT_REPO_VARS = [
3130
+ "GIT_DIR",
3131
+ "GIT_WORK_TREE",
3132
+ "GIT_COMMON_DIR",
3133
+ "GIT_INDEX_FILE",
3134
+ "GIT_OBJECT_DIRECTORY",
3135
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3136
+ "GIT_NAMESPACE",
3137
+ "GIT_PREFIX",
3138
+ ];
3139
+ /** git run in `cwd`, no shell, bounded; undefined when it could not run at all (not on PATH, timed out). */
3140
+ export function gitIn(cwd, args) {
3141
+ // The folder decides the repository, not a GIT_DIR the shell was started with.
3142
+ const env = { ...process.env };
3143
+ for (const k of GIT_REPO_VARS)
3144
+ delete env[k];
3145
+ try {
3146
+ const r = spawnSync("git", args, {
3147
+ cwd,
3148
+ env,
3149
+ encoding: "utf-8",
3150
+ timeout: GIT_TIMEOUT_MS,
3151
+ windowsHide: true,
3152
+ stdio: ["ignore", "pipe", "pipe"],
3153
+ });
3154
+ if (r.error || r.status === null)
3155
+ return undefined;
3156
+ return { status: r.status, stdout: r.stdout ?? "" };
3157
+ }
3158
+ catch {
3159
+ return undefined;
3160
+ }
3161
+ }
3162
+ /** Where `root` sits in its repository: the exclude file git reads for it, and `root` relative to the top level. */
3163
+ function repoOf(root) {
3164
+ const r = gitIn(root, ["rev-parse", "--is-inside-work-tree", "--show-prefix", "--git-path", "info/exclude"]);
3165
+ if (!r || r.status !== 0)
3166
+ return undefined;
3167
+ const lines = r.stdout.split(/\r?\n/);
3168
+ if (lines[0] !== "true" || lines.length < 3 || !lines[2])
3169
+ return undefined;
3170
+ return { exclude: path.resolve(root, lines[2]), prefix: lines[1] };
3171
+ }
3172
+ /** A path as a gitignore pattern matches it literally: the wildcard characters escaped, anchored at the top level. */
3173
+ function anchoredPattern(prefix, rel) {
3174
+ return "/" + `${prefix}${rel}`.replace(/[\\*?[]/g, "\\$&");
3175
+ }
3176
+ function readLines(file) {
3177
+ try {
3178
+ return fs.readFileSync(file, "utf-8").split("\n");
3179
+ }
3180
+ catch (e) {
3181
+ return e.code === "ENOENT" ? [] : undefined;
3182
+ }
3183
+ }
3184
+ /**
3185
+ * Put the project file (`rel`, relative to `root`) into the repository's
3186
+ * local exclude file (`git rev-parse --git-path info/exclude`: right for a
3187
+ * worktree and a submodule too), as two lines: ANTIGRAVITY_EXCLUDE_COMMENT
3188
+ * and the file's path anchored at the top level. Nothing when `root` is not
3189
+ * in a git work tree, git cannot be run, the file is ignored already or
3190
+ * tracked, or the line is there. Never .gitignore, never thrown: a setup
3191
+ * never fails on it. Returns true when the lines were added. Lockstep with
3192
+ * the extension.
3193
+ */
3194
+ export function excludeFromGit(root, rel = ANTIGRAVITY_PROJECT_MCP) {
3195
+ try {
3196
+ const repo = repoOf(root);
3197
+ if (!repo)
3198
+ return false;
3199
+ // Exit 1 is "not ignored" and "not tracked"; 0 is the opposite, and
3200
+ // anything else (a path beyond a symlink, a broken repository) is no
3201
+ // answer: nothing is written then either.
3202
+ const ignored = gitIn(root, ["check-ignore", "-q", "--", rel]);
3203
+ if (!ignored || ignored.status !== 1)
3204
+ return false;
3205
+ const tracked = gitIn(root, ["ls-files", "--error-unmatch", "--", rel]);
3206
+ if (!tracked || tracked.status !== 1)
3207
+ return false;
3208
+ const line = anchoredPattern(repo.prefix, rel);
3209
+ const lines = readLines(repo.exclude);
3210
+ if (!lines || lines.some((l) => l.replace(/\r$/, "") === line))
3211
+ return false;
3212
+ const text = lines.join("\n");
3213
+ const lead = text === "" || text.endsWith("\n") ? "" : "\n";
3214
+ fs.mkdirSync(path.dirname(repo.exclude), { recursive: true });
3215
+ fs.appendFileSync(repo.exclude, `${lead}${ANTIGRAVITY_EXCLUDE_COMMENT}\n${line}\n`);
3216
+ return true;
3217
+ }
3218
+ catch (err) {
3219
+ process.stderr.write(` [!] could not keep ${rel} out of git: ${err instanceof Error ? err.message : String(err)}\n`);
3220
+ return false;
3221
+ }
3222
+ }
3223
+ /**
3224
+ * The project file's path relative to the repository's top level when git
3225
+ * tracks it (committed or staged); undefined when it does not, when it is
3226
+ * not on disk, or when git cannot say. The name is git's, in git's letter
3227
+ * case (a committed agents.md asked as AGENTS.md is "agents.md" where
3228
+ * core.ignorecase is true). What the doctor names in its `git rm --cached`
3229
+ * line. Lockstep with the extension.
3230
+ */
3231
+ export function gitTrackedPath(root, rel = ANTIGRAVITY_PROJECT_MCP) {
3232
+ try {
3233
+ if (!fs.existsSync(path.join(root, rel)))
3234
+ return undefined;
3235
+ // git matches a pathspec by case even with core.ignorecase: on a
3236
+ // case-insensitive filesystem a committed agents.md, read here as
3237
+ // vexp's AGENTS.md, counted as untracked, and "Not here" edited or
3238
+ // deleted it (review, 2026-11-07). So git is asked case-blind
3239
+ // (`literal` also keeps glob characters in a name from being read as a
3240
+ // pattern), and a name in another case counts only where git itself
3241
+ // folds case, core.ignorecase=true, the rule git status follows. Never
3242
+ // decided by the OS: a Windows folder mounted in a Linux container is
3243
+ // case-insensitive too. A git too old for the magic exits 128: not
3244
+ // tracked, as before. -z: the name as it is, not C-quoted (core.quotePath).
3245
+ const want = rel.split(path.sep).join("/");
3246
+ const r = gitIn(root, ["ls-files", "-z", "--full-name", "--", `:(literal,icase)${want}`]);
3247
+ if (!r || r.status !== 0)
3248
+ return undefined;
3249
+ const names = r.stdout.split("\0").filter((n) => n !== "");
3250
+ const exact = names.find((n) => n === want || n.endsWith(`/${want}`));
3251
+ if (exact !== undefined)
3252
+ return exact;
3253
+ const lower = want.toLowerCase();
3254
+ const folded = names.find((n) => n.toLowerCase() === lower || n.toLowerCase().endsWith(`/${lower}`));
3255
+ if (folded === undefined)
3256
+ return undefined;
3257
+ const ic = gitIn(root, ["config", "--bool", "core.ignorecase"]);
3258
+ return ic?.status === 0 && ic.stdout.trim() === "true" ? folded : undefined;
3259
+ }
3260
+ catch {
3261
+ return undefined;
3262
+ }
3263
+ }
3264
+ // ---------------------------------------------------------------------------
3265
+ // Files committed to the repository (field report, 2026-11-07). A file git
3266
+ // tracks is checked out unchanged in every clone and every linked worktree:
3267
+ // a setup run in a worktree that rewrote it with its own path, or a cleanup
3268
+ // that deleted it, showed up as a change on that branch, and an agent's
3269
+ // `git add -A` carried it back to the main checkout on merge. So a writer
3270
+ // never rewrites a tracked project file only to point it at this checkout
3271
+ // (committedPin), and a cleanup never deletes or edits a tracked file
3272
+ // (committedCleanupBlocked). Both say so at the end of setup
3273
+ // (takeSetupNotes), in the words the extension logs. Lockstep with the
3274
+ // extension (agent-auto-config.ts).
3275
+ // ---------------------------------------------------------------------------
3276
+ /**
3277
+ * Is `file` tracked by git (`git ls-files --error-unmatch`, through
3278
+ * gitTrackedPath: no shell, the file's own folder as cwd, 3 s, the
3279
+ * GIT_DIR family removed)? Not in a repository, git missing, or the file
3280
+ * not on disk: not tracked.
3281
+ */
3282
+ export function isGitTracked(file) {
3283
+ return gitTrackedPath(path.dirname(file), path.basename(file)) !== undefined;
3284
+ }
3285
+ /** What a setup left as it is on purpose, said at its end. ASCII only. */
3286
+ const setupNotes = [];
3287
+ /** Drain the notes (call after a setup run to print them). */
3288
+ export function takeSetupNotes() {
3289
+ return setupNotes.splice(0, setupNotes.length);
3290
+ }
3291
+ /** Note a line once per run. */
3292
+ function noteSetup(line) {
3293
+ if (!setupNotes.includes(line))
3294
+ setupNotes.push(line);
3295
+ }
3296
+ /** Why a cleanup left a committed file alone. Word for word the extension's. */
3297
+ export function committedCleanupLine(file, agent) {
3298
+ return `${file} is committed to this repository, so vexp left it as it is - remove vexp's part from it in a commit if this project no longer uses ${agent}`;
3299
+ }
3300
+ /** Why a writer left a committed file pinned to another checkout alone. Word for word the extension's. */
3301
+ export function committedPinLine(file, other) {
3302
+ return `${file} is committed to this repository and names ${other}; vexp left it as it is`;
3303
+ }
3304
+ /**
3305
+ * Why a committed instruction file was left with the marker of another
3306
+ * vexp version (M1): its vexp section is, but for the version inside
3307
+ * `<!-- vexp v... -->`, what this vexp writes. Word for word the extension's.
3308
+ */
3309
+ export function committedMarkerOnlyLine(file) {
3310
+ return `${file} is committed to this repository and its vexp section differs only in the version marker; vexp left it as it is`;
3311
+ }
3312
+ /** Why .vscode/mcp.json gets no vexp entry (W4). Word for word the extension's. */
3313
+ export const MCP_JSON_DEFINES_VEXP = ".mcp.json already defines vexp, so vexp left it out of .vscode/mcp.json (VS Code would use that one and turn the .mcp.json entry off)";
3314
+ /**
3315
+ * The folder an MCP entry pins vexp to by name, read as the server the
3316
+ * entry runs reads it, or undefined for an entry that pins nothing.
3317
+ * vexp-core (`mcp`) lets `--workspace <dir>` / `--workspace=<dir>` win over
3318
+ * env VEXP_WORKSPACE (cmd_mcp reads the env only with no flag); the Node
3319
+ * server, an entry that runs a bundled mcp-server.cjs, parses no
3320
+ * --workspace and reads env only. Here, not in the doctor, because the
3321
+ * Copilot writer applies the same .mcp.json condition (dotMcpJsonMiss);
3322
+ * doctor.ts re-exports it. Lockstep with the extension's vexpPinOf.
3323
+ */
3324
+ export function vexpPinOf(entry) {
3325
+ if (!asPlainObject(entry))
3326
+ return undefined;
3327
+ const e = entry;
3328
+ const env = asPlainObject(e.env) ? e.env : undefined;
3329
+ const fromEnv = typeof env?.VEXP_WORKSPACE === "string" && env.VEXP_WORKSPACE.trim() ? { pin: env.VEXP_WORKSPACE, from: "env" } : undefined;
3330
+ const args = Array.isArray(e.args) ? e.args : [];
3331
+ if (args.some((a) => typeof a === "string" && /mcp-server\.cjs$/i.test(a)))
3332
+ return fromEnv;
3333
+ const i = args.indexOf("--workspace");
3334
+ if (i >= 0 && typeof args[i + 1] === "string" && args[i + 1].trim())
3335
+ return { pin: args[i + 1], from: "args" };
3336
+ const eq = args.find((a) => typeof a === "string" && a.startsWith("--workspace=") && a.length > 12);
3337
+ return eq ? { pin: eq.slice(12), from: "args" } : fromEnv;
3338
+ }
3339
+ /**
3340
+ * Do two spellings name one folder, by the extension's rule (mcp-folder.ts
3341
+ * samePath): the same spelling once resolved and without a trailing
3342
+ * separator, case-folded on Windows only, else the same folder once links
3343
+ * are resolved. The CLI's samePath folds case on every OS and resolves
3344
+ * links only on Windows, so these findings warned for a root spelled
3345
+ * through a link (/home -> /var/home, /tmp -> /private/tmp on macOS) that
3346
+ * the extension read as this workspace, and read /app/WT as /app/wt on
3347
+ * Linux (review, 2026-11-07). Not on darwin either: macOS realpath already
3348
+ * returns the case on disk, and folding would be wrong on a case-sensitive
3349
+ * APFS volume. The doctors' rule, used by the writer's .mcp.json condition
3350
+ * too (dotMcpJsonMiss). Lockstep with the extension.
3351
+ */
3352
+ export function sameFolderAsExtension(a, b) {
3353
+ const key = (p) => {
3354
+ const r = path.resolve(p).replace(/[\\/]+$/, "");
3355
+ const k = r.length > 0 ? r : path.resolve(p);
3356
+ return process.platform === "win32" ? k.toLowerCase() : k;
3357
+ };
3358
+ if (key(a) === key(b))
3359
+ return true;
3360
+ try {
3361
+ return key(fs.realpathSync.native(a)) === key(fs.realpathSync.native(b));
3362
+ }
3363
+ catch {
3364
+ return false; // one of them is not there: only the spelling can match
3365
+ }
3366
+ }
3367
+ /**
3368
+ * M2: would `entry`, the vexp server of `<root>/.mcp.json`, serve this
3369
+ * root when VS Code starts it? undefined when it would, else why not. Only
3370
+ * then may vexp leave its own entry out of .vscode/mcp.json (W4): VS Code
3371
+ * turns the .mcp.json server off as soon as .vscode/mcp.json defines one by
3372
+ * the same name, so leaving ours out hands Copilot the .mcp.json one, and a
3373
+ * committed .mcp.json pinned to the main checkout, or running a `vexp-core`
3374
+ * that is not on PATH (the npm CLI puts only `vexp` there), gave a worktree
3375
+ * window the main checkout's index or no vexp at all (field report,
3376
+ * 2026-11-07).
3377
+ * (a) the pin, as VS Code expands it in .mcp.json (dotMcpJsonPin): none,
3378
+ * this root (sameFolderAsExtension; a relative pin is read against
3379
+ * the root, the folder VS Code starts the server in), or an env
3380
+ * VEXP_WORKSPACE that still holds `${` or is blank (VS Code expands no
3381
+ * ${workspaceFolder} in .mcp.json, and both servers then fall back to
3382
+ * that folder, E3). A --workspace in args that names no folder fails:
3383
+ * vexp-core exits on it;
3384
+ * (b) the command: a stdio entry (an http or sse one fails) whose command,
3385
+ * expanded as VS Code does, is an absolute path to an executable file
3386
+ * or a bare name found on this process's PATH (with PATHEXT on
3387
+ * Windows), and, when it runs node or vexp's bundle, whose absolute
3388
+ * script paths in args exist.
3389
+ * The pin is asked first, so a line names the pin when both fail. Read only
3390
+ * the file system, never git. Lockstep with the extension's dotMcpJsonMiss.
3391
+ */
3392
+ export function dotMcpJsonMiss(entry, root, env = process.env, platform = process.platform) {
3393
+ // A --workspace that vexp-core cannot take (no value after it, or
3394
+ // `--workspace=` alone) stops it before it reads anything else.
3395
+ if (workspaceFlagWithoutValue(entry))
3396
+ return { pin: NO_WORKSPACE_VALUE };
3397
+ const p = vexpPinOf(entry);
3398
+ if (p !== undefined) {
3399
+ // The pin as VS Code hands it over from .mcp.json (dotMcpJsonPin), so
3400
+ // this line and D2 name the same folder for one entry.
3401
+ const folder = dotMcpJsonPin(p.pin, root, env, platform);
3402
+ if (folder !== undefined && !sameFolderAsExtension(folder, root))
3403
+ return { pin: folder };
3404
+ // Only an env pin that names no folder falls back to the folder: both
3405
+ // servers drop such a VEXP_WORKSPACE (utils::workspace_env_value,
3406
+ // workspaceEnv), while vexp-core canonicalizes a --workspace value and
3407
+ // exits on one that names no folder ("Cannot resolve workspace path").
3408
+ // A `--workspace ${workspaceFolder}` that VS Code moved verbatim from
3409
+ // .vscode/mcp.json into .mcp.json passed here, and vexp's working entry
3410
+ // was removed for a server that dies at start (review, 2026-11-08).
3411
+ if (folder === undefined && p.from === "args") {
3412
+ const text = dotMcpJsonExpand(p.pin, env, platform);
3413
+ return { pin: text.trim() === "" ? NO_WORKSPACE_VALUE : text };
3414
+ }
3415
+ }
3416
+ const command = dotMcpJsonCommandMissing(entry, env, platform);
3417
+ return command === undefined ? undefined : { command };
3418
+ }
3419
+ /** What the (a) line names for a --workspace with no usable value. ASCII, the extension's words. */
3420
+ export const NO_WORKSPACE_VALUE = "(none: --workspace has no value)";
3421
+ /**
3422
+ * Does `entry` (not one that runs vexp's Node bundle, which parses no
3423
+ * --workspace) hand vexp-core a --workspace it cannot take: the last arg,
3424
+ * one followed by a blank or a non-string, or `--workspace=` alone?
3425
+ * vexpPinOf reads no pin there and falls back to env, but vexp-core exits
3426
+ * on it first. Lockstep with the extension.
3427
+ */
3428
+ function workspaceFlagWithoutValue(entry) {
3429
+ if (!asPlainObject(entry) || !Array.isArray(entry.args))
3430
+ return false;
3431
+ const args = entry.args;
3432
+ if (args.some((a) => typeof a === "string" && /mcp-server\.cjs$/i.test(a)))
3433
+ return false;
3434
+ const i = args.indexOf("--workspace");
3435
+ if (i >= 0)
3436
+ return !(typeof args[i + 1] === "string" && args[i + 1].trim() !== "");
3437
+ return args.includes("--workspace=");
3438
+ }
3439
+ /** dotMcpJsonMiss (b): undefined when the entry would start here, else the command text its line names. */
3440
+ function dotMcpJsonCommandMissing(entry, env, platform) {
3441
+ const e = asPlainObject(entry) ? entry : {};
3442
+ if (typeof e.type === "string" && e.type.toLowerCase() !== "stdio")
3443
+ return `(none: a ${e.type} server)`;
3444
+ if (typeof e.command !== "string" || e.command.trim() === "")
3445
+ return "(none)";
3446
+ // VS Code expands the command and every arg of a .mcp.json entry from its
3447
+ // environment before it starts the server (dotMcpJsonExpand): what is
3448
+ // looked for here is what it runs.
3449
+ const command = dotMcpJsonExpand(e.command, env, platform);
3450
+ if (!commandFoundHere(command, env, platform))
3451
+ return command;
3452
+ const p = platform === "win32" ? path.win32 : path.posix;
3453
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string").map((a) => dotMcpJsonExpand(a, env, platform)) : [];
3454
+ const base = p.basename(command).toLowerCase().replace(/\.exe$/, "");
3455
+ if (base === "node" || base === "nodejs" || args.some((a) => /mcp-server\.cjs$/i.test(a))) {
3456
+ // A script needs to be there, not executable: node reads it.
3457
+ const missing = args.find((a) => p.isAbsolute(a) && /\.[cm]?js$/i.test(a) && !isPlainFile(a));
3458
+ if (missing !== undefined)
3459
+ return `${command} ${missing}`;
3460
+ }
3461
+ return undefined;
3462
+ }
3463
+ /** A command VS Code could start: an absolute path to an executable file, or a bare name on PATH (PATHEXT on Windows). A relative path names neither. */
3464
+ function commandFoundHere(command, env, platform) {
3465
+ const win = platform === "win32";
3466
+ if ((win ? path.win32 : path.posix).isAbsolute(command))
3467
+ return runnableFile(command, platform);
3468
+ if (/[\\/]/.test(command))
3469
+ return false;
3470
+ // Windows keeps the variable as Path; the lookup is case-blind there.
3471
+ const pathVar = win ? Object.entries(env).find(([k]) => k.toUpperCase() === "PATH")?.[1] : env.PATH;
3472
+ const pathExt = win ? Object.entries(env).find(([k]) => k.toUpperCase() === "PATHEXT")?.[1] : undefined;
3473
+ const exts = (pathExt ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((x) => x !== "");
3474
+ const names = !win ? [command] : exts.some((x) => command.toLowerCase().endsWith(x.toLowerCase())) ? [command] : exts.map((x) => command + x);
3475
+ for (const dir of (pathVar ?? "").split(win ? ";" : ":")) {
3476
+ if (dir === "")
3477
+ continue;
3478
+ if (names.some((n) => runnableFile(path.join(dir, n), platform)))
3479
+ return true;
3480
+ }
3481
+ return false;
3482
+ }
3483
+ function isPlainFile(p) {
3484
+ try {
3485
+ return fs.statSync(p).isFile();
3486
+ }
3487
+ catch {
3488
+ return false;
3489
+ }
3490
+ }
3491
+ /** A file, executable for this user except on Windows (where a file is enough). */
3492
+ function runnableFile(p, platform) {
3493
+ try {
3494
+ if (!fs.statSync(p).isFile())
3495
+ return false;
3496
+ if (platform !== "win32")
3497
+ fs.accessSync(p, fs.constants.X_OK);
3498
+ return true;
3499
+ }
3500
+ catch {
3501
+ return false;
3502
+ }
3503
+ }
3504
+ /**
3505
+ * Why vexp writes its own entry into .vscode/mcp.json although
3506
+ * `<root>/.mcp.json` defines vexp (M2): a setup note and the doctor's INFO
3507
+ * line, word for word the extension's. ASCII.
3508
+ */
3509
+ export function dotMcpJsonKeptLine(root, miss) {
3510
+ const file = path.join(root, ".mcp.json");
3511
+ return miss.pin !== undefined
3512
+ ? `${file} has a vexp server pinned to ${miss.pin}, so vexp keeps its own entry in .vscode/mcp.json, which VS Code uses here instead`
3513
+ : `${file} has a vexp server whose command ${miss.command} is not found here, so vexp keeps its own entry in .vscode/mcp.json, which VS Code uses here instead`;
3514
+ }
3515
+ /**
3516
+ * A linked worktree's .vscode/mcp.json that holds vexp alone, without the
3517
+ * servers of the main checkout's copy, which git does not track and so `git
3518
+ * worktree add` did not check out (W5). Word for word the extension's
3519
+ * worktreeMcpJsonLine. ASCII.
3520
+ */
3521
+ export function worktreeMcpJsonLine(file, mainFile, names) {
3522
+ return `${file} was created with vexp only: ${mainFile} is not tracked by git, so its other servers (${names.join(", ")}) are not in this worktree - copy them here, or move them to your user mcp.json so every window has them`;
3523
+ }
3524
+ /**
3525
+ * The W5 line for `file`, a .vscode/mcp.json inside the linked worktree
3526
+ * `workspaceRoot` that holds vexp alone, while the main checkout's copy at
3527
+ * the same place is untracked and holds other servers; undefined anywhere
3528
+ * else. What
3529
+ * `vexp doctor` says (D4): `git worktree add` checks out only what git
3530
+ * tracks, so a repository whose .gitignore leaves .vscode/* out gave the
3531
+ * worktree no copy, and a setup made one with vexp alone (field report,
3532
+ * 2026-11-07: "dropped teamdocs"). vexp never copies another server. Lockstep
3533
+ * with the extension's worktreeMcpJsonNote.
3534
+ */
3535
+ export function worktreeMcpJsonNote(workspaceRoot, file) {
3536
+ const main = linkedWorktreeMain(workspaceRoot);
3537
+ if (!main)
3538
+ return undefined;
3539
+ const rel = path.relative(workspaceRoot, file);
3540
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
3541
+ return undefined;
3542
+ const mainFile = path.join(main, rel);
3543
+ if (!fs.existsSync(mainFile) || !fs.existsSync(file) || isGitTracked(mainFile))
3544
+ return undefined;
3545
+ const theirs = readJsonConfigSafe(mainFile);
3546
+ const servers = theirs.ok ? theirs.data.servers : undefined;
3547
+ if (!asPlainObject(servers))
3548
+ return undefined;
3549
+ const here = readJsonConfigSafe(file);
3550
+ const ours = here.ok && asPlainObject(here.data.servers) ? Object.keys(here.data.servers) : [];
3551
+ // The line says the file "was created with vexp only" (W5): true only
3552
+ // while it holds vexp alone. A file with a server of the user's, or none,
3553
+ // or one that does not parse, was made or changed by hand, and the doctor,
3554
+ // which asks at every run, misstated it (review, 2026-11-07).
3555
+ if (ours.length === 0 || ours.some((n) => n.toLowerCase() !== "vexp"))
3556
+ return undefined;
3557
+ const names = Object.keys(servers).filter((n) => n.toLowerCase() !== "vexp");
3558
+ return names.length > 0 ? worktreeMcpJsonLine(file, mainFile, names) : undefined;
3559
+ }
3560
+ /**
3561
+ * True when a cleanup for `agent` must not run: one of `files` (those that
3562
+ * exist) is committed to this repository. Each such file gets
3563
+ * committedCleanupLine. The caller then changes none of them, so a hook
3564
+ * script and the entry that runs it stay together.
3565
+ */
3566
+ function committedCleanupBlocked(files, agent) {
3567
+ let blocked = false;
3568
+ for (const f of files) {
3569
+ if (!f || !fs.existsSync(f) || !isGitTracked(f))
3570
+ continue;
3571
+ noteSetup(committedCleanupLine(f, agent));
3572
+ blocked = true;
3573
+ }
3574
+ return blocked;
3575
+ }
3576
+ /** The hook scripts the latest hookScriptCurrent call for each left as committed (K1): what the setup report must not call written. */
3577
+ const hookScriptsLeft = new Set();
3578
+ /** Did the latest setup leave the committed hook script `file` as it was (hookScriptCurrent)? */
3579
+ export function hookScriptLeft(file) {
3580
+ return hookScriptsLeft.has(file);
3581
+ }
3582
+ /**
3583
+ * Does the hook script at `file` need no write: it holds `wanted` already,
3584
+ * or it is committed to the repository, holds exactly what this vexp writes
3585
+ * for the engine path it bakes (`plainFor`, in this CLI's form or with the
3586
+ * extension's upgrade fallback) and that path still finds a working engine
3587
+ * here (K1, hook-scripts.ts committedHookScriptStays)? The second case is
3588
+ * said at the end of setup (committedHookScriptLine). Every writer of a
3589
+ * script with a baked engine path asks here first: another machine, an
3590
+ * extension update or the extension's own copy rewrote the committed
3591
+ * .claude/hooks and .codex scripts in every checkout, and `git add -A`
3592
+ * committed the churn (field report, 2026-11-07). git runs only for a
3593
+ * script that differs from `wanted` and is this vexp's text for another
3594
+ * engine path. Lockstep with the extension's hookScriptCurrent.
3595
+ */
3596
+ function hookScriptCurrent(file, wanted, plainFor) {
3597
+ hookScriptsLeft.delete(file);
3598
+ const text = readHookScript(file);
3599
+ if (text === undefined)
3600
+ return false;
3601
+ if (text === wanted)
3602
+ return true;
3603
+ if (!committedHookScriptStays(text, plainFor, () => isGitTracked(file)))
3604
+ return false;
3605
+ noteSetup(committedHookScriptLine(file));
3606
+ hookScriptsLeft.add(file);
3607
+ return true;
3608
+ }
3609
+ /**
3610
+ * The folder a vexp pin names once VS Code has expanded it: `root` for
3611
+ * "${workspaceFolder}" or "${workspaceFolder:<name>}" alone (optionally
3612
+ * followed by one separator); ${userHome} becomes the home folder and
3613
+ * ${env:NAME} the variable's value. undefined when a variable is left that
3614
+ * cannot be expanded here (${input:...}, ${config:...},
3615
+ * ${workspaceFolder}/sub, an unset ${env:NAME}): such a pin is not
3616
+ * compared. "Any pin with ${ is this root" let a committed
3617
+ * "${userHome}/src/app", which VS Code expands to one folder in every
3618
+ * checkout, serve that folder's index to each worktree with no word from
3619
+ * either doctor (review, 2026-11-07). Lockstep with the extension's.
3620
+ */
3621
+ export function expandedPin(pin, root, env = process.env, home = os.homedir()) {
3622
+ if (/^\$\{workspaceFolder(?::[^}]*)?\}[\\/]?$/.test(pin))
3623
+ return root;
3624
+ let unresolved = false;
3625
+ const out = pin.replace(/\$\{(userHome|env:([^}]+))\}/g, (_m, v, name) => {
3626
+ if (v === "userHome")
3627
+ return home;
3628
+ const val = name === undefined ? undefined : env[name];
3629
+ if (!val)
3630
+ unresolved = true;
3631
+ return val ?? "";
3632
+ });
3633
+ return unresolved || out.includes("${") ? undefined : out;
3634
+ }
3635
+ /**
3636
+ * A value of <root>/.mcp.json as VS Code (1.142+) hands it to the server,
3637
+ * by the Copilot CLI's rules it follows there (mcpEnvironmentVariableExpansion,
3638
+ * envVarResolution): `${NAME}` and `${NAME:-default}` take NAME
3639
+ * (`[A-Za-z_][A-Za-z0-9_]*`) from `env`, case-blind on Windows; a variable
3640
+ * set to "" gives "", and a default is put in as it is, never expanded
3641
+ * again. An unset name with no default, a bare `$NAME`, and the variables
3642
+ * of .vscode/mcp.json (`${userHome}`, `${env:X}`, `${workspaceFolder}`,
3643
+ * `${input:x}`) stay verbatim; a value over 1000 characters is left whole.
3644
+ * A default ends at its first `}` here: VS Code also balances `${...}` and
3645
+ * `$(...)` inside one, which a vexp pin or command has no use for. Read
3646
+ * with expandedPin, .vscode/mcp.json's rules, "${userHome}/elsewhere" got
3647
+ * a D2 warning beside D4's [OK] for one entry, and "${HOME}", which VS Code
3648
+ * does expand, passed M2 (review, 2026-11-08). Lockstep with the
3649
+ * extension's.
3650
+ */
3651
+ export function dotMcpJsonExpand(value, env = process.env, platform = process.platform) {
3652
+ if (value.length > 1000)
3653
+ return value;
3654
+ const lookup = (name) => {
3655
+ if (env[name] !== undefined || platform !== "win32")
3656
+ return env[name];
3657
+ const key = Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
3658
+ return key === undefined ? undefined : env[key];
3659
+ };
3660
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (whole, name, fallback) => {
3661
+ const v = lookup(name);
3662
+ if (v !== undefined)
3663
+ return v;
3664
+ return fallback ?? whole;
3665
+ });
3666
+ }
3667
+ /**
3668
+ * The folder a vexp pin of <root>/.mcp.json names once VS Code has
3669
+ * expanded it (dotMcpJsonExpand), read against `root`, the folder VS Code
3670
+ * starts the server in; undefined when it names none: what is left still
3671
+ * holds `${`, or is blank. Both servers drop such a VEXP_WORKSPACE and
3672
+ * serve that folder (E3). M2 (a) and D2 read .mcp.json through it, so the
3673
+ * two lines agree on one entry; .vscode/mcp.json is read with expandedPin.
3674
+ * Lockstep with the extension's.
3675
+ */
3676
+ export function dotMcpJsonPin(pin, root, env = process.env, platform = process.platform) {
3677
+ const value = dotMcpJsonExpand(pin, env, platform);
3678
+ if (value.trim() === "" || value.includes("${"))
3679
+ return undefined;
3680
+ return path.resolve(root, value);
3681
+ }
3682
+ /** The VEXP_WORKSPACE of an entry's env, if it has one. */
3683
+ function envPin(env) {
3684
+ return asPlainObject(env) ? env.VEXP_WORKSPACE : undefined;
3685
+ }
3686
+ /**
3687
+ * The pin a writer keeps in a committed project file: `file` is inside
3688
+ * `workspaceRoot` and tracked by git, and vexp's entry there is pinned
3689
+ * (`prevPin`) to a folder other than this one (another checkout's absolute
3690
+ * path, `other`), or by a variable the agent expands (`${workspaceFolder}`,
3691
+ * the same text in every checkout). The writer then leaves the pin as it
3692
+ * is, and touches the entry only when the vexp install it runs is gone
3693
+ * (staleVexpInstall). undefined when the writer pins as before: no pin, a
3694
+ * relative one, this folder in any spelling, a machine-wide file, an
3695
+ * untracked one. git runs only for an entry pinned so. Lockstep with the
3696
+ * extension's committedPinElsewhere.
3697
+ */
3698
+ function committedPin(file, workspaceRoot, prevPin) {
3699
+ if (!pinnedProjectFile(file, workspaceRoot))
3700
+ return undefined;
3701
+ if (typeof prevPin !== "string" || prevPin === "")
3702
+ return undefined;
3703
+ const variable = prevPin.includes("${");
3704
+ if (!variable && (!path.isAbsolute(prevPin) || sameFolder(prevPin, workspaceRoot)))
3705
+ return undefined;
3706
+ // Another checkout of this repository on this machine only: a pin a
3707
+ // teammate's clone, a moved folder or a removed worktree left names a
3708
+ // folder that is not a checkout of this repository here, and the writer
3709
+ // pins as before (sameRepositoryCheckout).
3710
+ if (!variable && !sameRepositoryCheckout(prevPin, workspaceRoot))
3711
+ return undefined;
3712
+ if (!isGitTracked(file))
3713
+ return undefined;
3714
+ if (!variable)
3715
+ return { pin: prevPin, other: prevPin };
3716
+ // A variable that VS Code expands to one folder in every checkout
3717
+ // (${userHome}/..., ${env:NAME}) is kept as before, and named when that
3718
+ // folder is not this one (expandedPin).
3719
+ const folder = expandedPin(prevPin, workspaceRoot);
3720
+ return folder !== undefined && path.isAbsolute(folder) && !sameFolder(folder, workspaceRoot)
3721
+ ? { pin: prevPin, other: folder }
3722
+ : { pin: prevPin };
3723
+ }
3724
+ /** Say why a committed file pinned elsewhere was left alone; false, for the writers' return. */
3725
+ function noteCommittedPin(file, kept) {
3726
+ if (kept.other)
3727
+ noteSetup(committedPinLine(file, kept.other));
3728
+ return false;
3729
+ }
3730
+ /**
3731
+ * Does `entry` run a vexp install that is gone? Its command or a script it
3732
+ * names lies in a vexp install (namesVexpInstall: an extension folder, the
3733
+ * npm package's binary, a bundled mcp-server.cjs) and does not resolve any
3734
+ * more. In every shape the writers read (Kilo's command array, Zed's nested
3735
+ * command). The only reason to touch an entry in a committed file another
3736
+ * checkout pinned, or a `.mcp.json` the user keeps: a bare `node` or another
3737
+ * install's working path is the same on every checkout. The CLI's own entry
3738
+ * names no versioned folder, so "older than this install" never applies
3739
+ * here; the extension's twin also moves one into an older extension folder.
3740
+ */
3741
+ function staleVexpInstall(entry) {
3742
+ if (!asPlainObject(entry))
3743
+ return false;
3744
+ let command = entry.command;
3745
+ let args = entry.args;
3746
+ if (Array.isArray(command)) {
3747
+ args = command.slice(1);
3748
+ command = command[0];
3749
+ }
3750
+ else if (asPlainObject(command)) {
3751
+ args = command.args;
3752
+ command = command.path;
3753
+ }
3754
+ const flat = { command, args };
3755
+ return namesVexpInstall(flat) && !vexpEntryStillResolves(entry);
3756
+ }
3757
+ /**
3758
+ * The layout of a JSON(C) text whose top level is an object: comments,
3759
+ * trailing commas and a BOM allowed, by the rules removeJsoncMember reads
3760
+ * them. undefined when the text is not shaped so.
3761
+ */
3762
+ function jsoncLayout(text) {
3763
+ const n = text.length;
3764
+ const skip = (i) => {
3765
+ for (;;) {
3766
+ while (i < n && /\s/.test(text[i]))
3767
+ i++; // \s takes a BOM too
3768
+ if (text[i] === "/" && text[i + 1] === "/") {
3769
+ while (i < n && text[i] !== "\n")
3770
+ i++;
3771
+ }
3772
+ else if (text[i] === "/" && text[i + 1] === "*") {
3773
+ const close = text.indexOf("*/", i + 2);
3774
+ if (close < 0)
3775
+ throw new Error("open comment");
3776
+ i = close + 2;
3777
+ }
3778
+ else {
3779
+ return i;
3780
+ }
3781
+ }
3782
+ };
3783
+ const str = (i) => {
3784
+ for (i++; i < n; i++) {
3785
+ if (text[i] === "\\")
3786
+ i++;
3787
+ else if (text[i] === '"')
3788
+ return i + 1;
3789
+ }
3790
+ throw new Error("open string");
3791
+ };
3792
+ const value = (i) => {
3793
+ if (text[i] === '"')
3794
+ return { start: i, end: str(i), kind: "other", members: [] };
3795
+ if (text[i] === "{" || text[i] === "[") {
3796
+ const close = text[i] === "{" ? "}" : "]";
3797
+ const members = [];
3798
+ let j = skip(i + 1);
3799
+ while (text[j] !== close) {
3800
+ if (j >= n)
3801
+ throw new Error("open container");
3802
+ const start = j;
3803
+ let key = "";
3804
+ if (close === "}") {
3805
+ if (text[j] !== '"')
3806
+ throw new Error("key expected");
3807
+ const keyEnd = str(j);
3808
+ key = JSON.parse(text.slice(j, keyEnd));
3809
+ j = skip(keyEnd);
3810
+ if (text[j] !== ":")
3811
+ throw new Error(": expected");
3812
+ j = skip(j + 1);
3813
+ }
3814
+ const v = value(j);
3815
+ const m = { key, start, end: v.end, value: v };
3816
+ j = skip(v.end);
3817
+ if (text[j] === ",") {
3818
+ m.comma = j;
3819
+ j = skip(j + 1);
3820
+ }
3821
+ else if (text[j] !== close) {
3822
+ throw new Error(", expected");
3823
+ }
3824
+ members.push(m);
3825
+ }
3826
+ return { start: i, end: j + 1, kind: close === "}" ? "object" : "array", members };
3827
+ }
3828
+ let end = i;
3829
+ while (end < n && !/[\s,}\]\/]/.test(text[end]))
3830
+ end++;
3831
+ if (end === i)
3832
+ throw new Error("value expected");
3833
+ return { start: i, end, kind: "other", members: [] };
3834
+ };
3835
+ try {
3836
+ const at = skip(0);
3837
+ if (text[at] !== "{")
3838
+ return undefined;
3839
+ const top = value(at);
3840
+ if (skip(top.end) !== n)
3841
+ return undefined;
3842
+ return top;
3843
+ }
3844
+ catch {
3845
+ return undefined;
3846
+ }
3847
+ }
3848
+ /** The blanks that open the line `pos` is on. */
3849
+ function lineIndent(text, pos) {
3850
+ let s = pos;
3851
+ while (s > 0 && text[s - 1] !== "\n")
3852
+ s--;
3853
+ let e = s;
3854
+ while (e < pos && (text[e] === " " || text[e] === "\t"))
3855
+ e++;
3856
+ return text.slice(s, e);
3857
+ }
3858
+ /** Is `pos` the first thing on its line? */
3859
+ function startsLine(text, pos) {
3860
+ let s = pos;
3861
+ while (s > 0 && (text[s - 1] === " " || text[s - 1] === "\t"))
3862
+ s--;
3863
+ return s === 0 || text[s - 1] === "\n";
3864
+ }
3865
+ /**
3866
+ * `value` set at `keys` (a path of object keys from the top level) in a
3867
+ * JSON(C) text, and nothing else changed: the user's comments, BOM, key
3868
+ * order, layout and last newline stay. A member that is there gets its
3869
+ * value replaced where it stands; one that is not is added after the last
3870
+ * member of its object (an object on the way that is missing is added the
3871
+ * same way, holding the rest of the path). The new text is indented as the
3872
+ * file is. undefined when the text is not shaped so, a key on the path is
3873
+ * there twice or is not an object, or the result would not parse to
3874
+ * exactly the same data with that value set: the caller then writes the
3875
+ * file as it did before. The counterpart of removeJsoncMember. Lockstep
3876
+ * with the extension.
3877
+ */
3878
+ export function upsertJsoncMember(text, keys, value) {
3879
+ if (keys.length === 0)
3880
+ return undefined;
3881
+ const top = jsoncLayout(text);
3882
+ if (!top)
3883
+ return undefined;
3884
+ const nl = text.includes("\r\n") ? "\r\n" : "\n";
3885
+ let unit = " ";
3886
+ const base = lineIndent(text, top.start);
3887
+ for (const m of top.members) {
3888
+ if (!startsLine(text, m.start))
3889
+ continue;
3890
+ const ind = lineIndent(text, m.start);
3891
+ if (ind.startsWith(base) && ind.length > base.length)
3892
+ unit = ind.slice(base.length);
3893
+ break;
3894
+ }
3895
+ const render = (v, indent) => JSON.stringify(v, null, unit).split("\n").join(nl + indent);
3896
+ let node = top;
3897
+ let depth = 0;
3898
+ for (; depth < keys.length - 1; depth++) {
3899
+ const hits = node.members.filter((m) => m.key === keys[depth]);
3900
+ if (hits.length > 1)
3901
+ return undefined;
3902
+ if (hits.length === 0)
3903
+ break;
3904
+ if (hits[0].value.kind !== "object")
3905
+ return undefined;
3906
+ node = hits[0].value;
3907
+ }
3908
+ const hits = node.members.filter((m) => m.key === keys[depth]);
3909
+ if (hits.length > 1)
3910
+ return undefined;
3911
+ let out;
3912
+ if (depth === keys.length - 1 && hits.length === 1) {
3913
+ const m = hits[0];
3914
+ // A list or a value that sat on one line stays on one line (the args of
3915
+ // a one-line entry); an object is laid out as the file is, unless it
3916
+ // shares its line with other members (a one-line file), where it stays
3917
+ // on that line: laid out, it broke the line in two. The extension's
3918
+ // twin keeps the same rule, so both write the same bytes.
3919
+ const oneLine = !text.slice(m.value.start, m.value.end).includes("\n") && (!asPlainObject(value) || !startsLine(text, m.start));
3920
+ out = text.slice(0, m.value.start) + (oneLine ? JSON.stringify(value) : render(value, lineIndent(text, m.start))) + text.slice(m.value.end);
3921
+ }
3922
+ else {
3923
+ let v = value;
3924
+ for (let k = keys.length - 1; k > depth; k--)
3925
+ v = { [keys[k]]: v };
3926
+ const key = JSON.stringify(keys[depth]);
3927
+ const parentIndent = lineIndent(text, node.start);
3928
+ if (node.members.length === 0) {
3929
+ const indent = parentIndent + unit;
3930
+ const open = node.start + 1;
3931
+ const inner = text.slice(open, node.end - 1);
3932
+ out =
3933
+ inner.trim() === ""
3934
+ ? text.slice(0, open) + nl + indent + key + ": " + render(v, indent) + nl + parentIndent + text.slice(node.end - 1)
3935
+ : text.slice(0, open) + nl + indent + key + ": " + render(v, indent) + text.slice(open);
3936
+ }
3937
+ else {
3938
+ const last = node.members[node.members.length - 1];
3939
+ const after = last.comma !== undefined ? last.comma + 1 : last.end;
3940
+ const close = node.end - 1;
3941
+ const comma = last.comma === undefined ? "," : "";
3942
+ // The rest of the last member's line: blanks and a // comment, which
3943
+ // stay with it; the new member goes on the line after.
3944
+ let q = after;
3945
+ while (q < close && (text[q] === " " || text[q] === "\t"))
3946
+ q++;
3947
+ if (text[q] === "/" && text[q + 1] === "/")
3948
+ while (q < close && text[q] !== "\n")
3949
+ q++;
3950
+ if (text[q] === "\r" && text[q + 1] === "\n")
3951
+ q++;
3952
+ if (text[q] === "\n" && q < close) {
3953
+ const at = q + 1;
3954
+ const indent = startsLine(text, last.start) ? lineIndent(text, last.start) : parentIndent + unit;
3955
+ out = text.slice(0, last.end) + comma + text.slice(last.end, at) + indent + key + ": " + render(v, indent) + nl + text.slice(at);
3956
+ }
3957
+ else {
3958
+ // All on one line: the member goes right after the last one.
3959
+ out = text.slice(0, after) + (comma ? ", " : " ") + key + ": " + JSON.stringify(v) + text.slice(after);
3960
+ }
3961
+ }
3962
+ }
3963
+ try {
3964
+ const want = parseJsonc(text);
3965
+ let o = want;
3966
+ for (let k = 0; k < keys.length - 1; k++) {
3967
+ if (!asPlainObject(o[keys[k]]))
3968
+ o[keys[k]] = {};
3969
+ o = o[keys[k]];
3970
+ }
3971
+ o[keys[keys.length - 1]] = value;
3972
+ return JSON.stringify(parseJsonc(out)) === JSON.stringify(want) ? out : undefined;
3973
+ }
3974
+ catch {
3975
+ return undefined;
3976
+ }
3977
+ }
3978
+ /**
3979
+ * The text a JSON MCP writer saves, and the encoding to save it in: `want`,
3980
+ * the data it means to write, which differs from the file's data only by
3981
+ * vexp's member `key`.`inner` (set to `entry`) and the legacy members it
3982
+ * took out (`removed`). A file that is absent or blank is written whole,
3983
+ * as before; one in exactly the layout JSON.stringify(data, null, 2) gives
3984
+ * (what vexp itself writes) gets the bytes the full rewrite gave before,
3985
+ * its BOM and last newline kept; any other file gets only those members
3986
+ * edited in its text (upsertJsoncMember, removeJsoncMember), so its
3987
+ * comments, BOM, layout and last newline stay. Bytes that are not UTF-8
3988
+ * are edited one latin1 character per byte when vexp's own value is ASCII,
3989
+ * so a teammate's `jos\xe9` stays one byte: the full rewrite saved it as
3990
+ * U+FFFD and dropped every comment. If the edit cannot be made safely, the
3991
+ * whole file, as before. The same algorithm as the extension's
3992
+ * memberEditedText, so both twins write the same bytes (worktree-lockstep).
3993
+ */
3994
+ function memberEditedText(file, want, key, inner, entry, removed = []) {
3995
+ const whole = { text: JSON.stringify(want, null, 2), encoding: "utf-8" };
3996
+ let bytes;
3997
+ try {
3998
+ bytes = fs.readFileSync(file);
3999
+ }
4000
+ catch {
4001
+ return whole;
4002
+ }
4003
+ let raw;
4004
+ let encoding = "utf-8";
4005
+ try {
4006
+ raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
4007
+ }
4008
+ catch {
4009
+ if (!/^[\x00-\x7f]*$/.test(JSON.stringify(entry)))
4010
+ return whole;
4011
+ raw = bytes.toString("latin1");
4012
+ encoding = "latin1";
4013
+ }
4014
+ const bom = raw.charCodeAt(0) === 0xfeff ? "" : "";
4015
+ const body = raw.slice(bom.length);
4016
+ if (body.trim() === "")
4017
+ return whole;
4018
+ try {
4019
+ const canon = JSON.stringify(JSON.parse(body), null, 2);
4020
+ if (body.startsWith(canon) && body.slice(canon.length).trim() === "") {
4021
+ return { text: bom + JSON.stringify(want, null, 2) + body.slice(canon.length), encoding };
4022
+ }
4023
+ }
4024
+ catch {
4025
+ /* JSONC: edited in its text */
4026
+ }
4027
+ let text = raw;
4028
+ for (const k of removed) {
4029
+ text = removeJsoncMember(text, key, k);
4030
+ if (text === undefined)
4031
+ return whole;
4032
+ }
4033
+ text = upsertJsoncMember(text, [key, inner], entry);
4034
+ if (text === undefined)
4035
+ return whole;
4036
+ try {
4037
+ return JSON.stringify(parseJsonc(text)) === JSON.stringify(want) ? { text, encoding } : whole;
4038
+ }
4039
+ catch {
4040
+ return whole;
4041
+ }
4042
+ }
4043
+ /**
4044
+ * Every real path a write to one of Antigravity's machine-wide files lands
4045
+ * on: each file's own, and its folder's real path joined with its name, so
4046
+ * a link into a folder that holds no such file yet counts as well.
4047
+ */
4048
+ function antigravityMachineMcpLocations() {
4049
+ const out = [];
4050
+ for (const p of antigravityMachineMcpNames()) {
4051
+ for (const real of [() => fs.realpathSync(p), () => path.join(fs.realpathSync(path.dirname(p)), path.basename(p))]) {
4052
+ try {
4053
+ const r = real();
4054
+ if (!out.includes(r))
4055
+ out.push(r);
4056
+ }
4057
+ catch {
4058
+ /* absent */
4059
+ }
4060
+ }
4061
+ }
4062
+ return out;
4063
+ }
4064
+ /**
4065
+ * `text` with the member `inner` of the top-level object's `outer` object
4066
+ * cut out, and nothing else changed: the user's comments, trailing commas,
4067
+ * BOM and layout stay. Undefined when the text is not shaped so, a key is
4068
+ * there twice, or the cut would not leave exactly the same data less that
4069
+ * member: the caller then leaves the file as it is. Lockstep with the
4070
+ * extension.
4071
+ */
4072
+ export function removeJsoncMember(text, outer, inner) {
4073
+ const n = text.length;
4074
+ const skip = (i) => {
4075
+ for (;;) {
4076
+ while (i < n && /\s/.test(text[i]))
4077
+ i++; // \s takes a BOM too
4078
+ if (text[i] === "/" && text[i + 1] === "/") {
4079
+ while (i < n && text[i] !== "\n")
4080
+ i++;
4081
+ }
4082
+ else if (text[i] === "/" && text[i + 1] === "*") {
4083
+ const close = text.indexOf("*/", i + 2);
4084
+ if (close < 0)
4085
+ throw new Error("open comment");
4086
+ i = close + 2;
4087
+ }
4088
+ else {
4089
+ return i;
4090
+ }
4091
+ }
4092
+ };
4093
+ const str = (i) => {
4094
+ for (i++; i < n; i++) {
4095
+ if (text[i] === "\\")
4096
+ i++;
4097
+ else if (text[i] === '"')
4098
+ return i + 1;
4099
+ }
4100
+ throw new Error("open string");
4101
+ };
4102
+ const container = (i) => {
4103
+ const close = text[i] === "{" ? "}" : "]";
4104
+ const members = [];
4105
+ i = skip(i + 1);
4106
+ while (text[i] !== close) {
4107
+ if (i >= n)
4108
+ throw new Error("open container");
4109
+ const start = i;
4110
+ let key = "";
4111
+ if (close === "}") {
4112
+ if (text[i] !== '"')
4113
+ throw new Error("key expected");
4114
+ const keyEnd = str(i);
4115
+ key = JSON.parse(text.slice(i, keyEnd));
4116
+ i = skip(keyEnd);
4117
+ if (text[i] !== ":")
4118
+ throw new Error(": expected");
4119
+ i = skip(i + 1);
4120
+ }
4121
+ const valueStart = i;
4122
+ let end;
4123
+ if (text[i] === '"')
4124
+ end = str(i);
4125
+ else if (text[i] === "{" || text[i] === "[")
4126
+ end = container(i).end;
4127
+ else {
4128
+ end = i;
4129
+ while (end < n && !/[\s,}\]\/]/.test(text[end]))
4130
+ end++;
4131
+ if (end === i)
4132
+ throw new Error("value expected");
4133
+ }
4134
+ const m = { key, start, valueStart, end };
4135
+ i = skip(end);
4136
+ if (text[i] === ",") {
4137
+ m.comma = i;
4138
+ i = skip(i + 1);
4139
+ }
4140
+ else if (text[i] !== close) {
4141
+ throw new Error(", expected");
4142
+ }
4143
+ members.push(m);
4144
+ }
4145
+ return { end: i + 1, members };
4146
+ };
4147
+ try {
4148
+ const at = skip(0);
4149
+ if (text[at] !== "{")
4150
+ return undefined;
4151
+ const top = container(at);
4152
+ if (skip(top.end) !== n)
4153
+ return undefined;
4154
+ const outers = top.members.filter((m) => m.key === outer);
4155
+ if (outers.length !== 1 || text[outers[0].valueStart] !== "{")
4156
+ return undefined;
4157
+ const members = container(outers[0].valueStart).members;
4158
+ const hits = members.filter((m) => m.key === inner);
4159
+ if (hits.length !== 1)
4160
+ return undefined;
4161
+ const hit = hits[0];
4162
+ const k = members.indexOf(hit);
4163
+ // The member with its own comma; the last one without, and then the
4164
+ // comma before it (only that byte: a comment after it is the user's).
4165
+ const cuts = [];
4166
+ let from = hit.start;
4167
+ let to = hit.comma !== undefined ? hit.comma + 1 : hit.end;
4168
+ let a = from;
4169
+ while (a > 0 && (text[a - 1] === " " || text[a - 1] === "\t"))
4170
+ a--;
4171
+ let b = to;
4172
+ while (b < n && (text[b] === " " || text[b] === "\t"))
4173
+ b++;
4174
+ if ((a === 0 || text[a - 1] === "\n") && (text[b] === "\n" || (text[b] === "\r" && text[b + 1] === "\n"))) {
4175
+ // The member's own line: the whole line goes.
4176
+ from = a;
4177
+ to = b + (text[b] === "\r" ? 2 : 1);
4178
+ }
4179
+ else if (hit.comma !== undefined) {
4180
+ to = b; // and the blanks after its comma
4181
+ }
4182
+ else {
4183
+ from = a; // and the blanks before it
4184
+ }
4185
+ cuts.push([from, to]);
4186
+ if (hit.comma === undefined && k > 0) {
4187
+ const before = members[k - 1].comma;
4188
+ cuts.unshift([before, before + 1]);
4189
+ }
4190
+ let out = text;
4191
+ for (const [s, e] of [...cuts].reverse())
4192
+ out = out.slice(0, s) + out.slice(e);
4193
+ // Same data less that member, or nothing.
4194
+ const want = parseJsonc(text);
4195
+ delete want[outer][inner];
4196
+ return JSON.stringify(parseJsonc(out)) === JSON.stringify(want) ? out : undefined;
4197
+ }
4198
+ catch {
4199
+ return undefined;
4200
+ }
4201
+ }
4202
+ /**
4203
+ * The machine-wide files vexp may take its dead entry out of
4204
+ * (antigravityFilesVexpCleans: the 1.x file while it is a regular file, or
4205
+ * the file the 1.x name links to) that hold one now, by real path.
4206
+ */
4207
+ function deadAntigravityEntryCleanables() {
4208
+ return antigravityFilesVexpCleans(os.homedir()).filter(({ file }) => {
4209
+ const read = readJsonConfigSafe(file);
4210
+ const servers = read.ok ? read.data.mcpServers : undefined;
4211
+ return asPlainObject(servers) && isDeadAntigravityEntry(servers["vexp"]);
4212
+ });
4213
+ }
4214
+ /** The files deadAntigravityEntryCleanables names, for the dry run. */
4215
+ export function deadAntigravityEntryFiles() {
4216
+ return deadAntigravityEntryCleanables().map((c) => c.file);
4217
+ }
4218
+ /**
4219
+ * Take out the machine-wide Antigravity entries earlier setups wrote
4220
+ * (isDeadAntigravityEntry): up to 3.3.9 every start rewrote an unpinned one
4221
+ * into the 1.x file, ~/.gemini/antigravity/mcp_config.json. A pinned entry
4222
+ * and one the user made stay; so does the rest of each file: one that is
4223
+ * not strict JSON (the user's comments, trailing commas, a BOM) loses only
4224
+ * vexp's member (removeJsoncMember) and keeps its text, and one that cannot
4225
+ * be cut so, or does not parse, is left as found.
4226
+ *
4227
+ * Only where vexp's entry can be (antigravityFilesVexpCleans): the 1.x file
4228
+ * while it is a regular file, and the file the 1.x name links to. The first
4229
+ * start of the Antigravity IDE or of its VS Code extension MOVES the 1.x
4230
+ * file onto ~/.gemini/config/mcp_config.json, replacing the user's global
4231
+ * servers, and leaves the 1.x name a link to it, through which 3.3.9 kept
4232
+ * writing (agy 1.5.3, measured 2026-11-07). The global file reached any
4233
+ * other way is the user's or Antigravity's: never edited, the doctor names
4234
+ * what is in it.
4235
+ *
4236
+ * A regular 1.x file left with no server and no other key is deleted, not
4237
+ * written empty, with no backup (it held only vexp's own entry), with an
4238
+ * old mcp_config.json.vexp-bak beside it, and then ~/.gemini/antigravity
4239
+ * when that is empty: a leftover file there is what Antigravity's first
4240
+ * start moves over the user's global servers, and the empty folder vexp
4241
+ * made looked like Antigravity on the machine (antigravityOnThisMachine).
4242
+ * Through the link the target is never deleted, even when it is left with
4243
+ * no servers: it is Antigravity's global file. Same bytes as the extension.
4244
+ * Returns the files changed or deleted.
4245
+ */
4246
+ export function removeDeadAntigravityEntries() {
4247
+ const cleaned = [];
4248
+ const deleted = [];
4249
+ for (const { file, viaLink } of deadAntigravityEntryCleanables()) {
4250
+ try {
4251
+ const cut = withoutVexpMember(fs.readFileSync(file), "mcpServers");
4252
+ if (cut === undefined) {
4253
+ process.stdout.write(` - vexp's old Antigravity entry (no VEXP_WORKSPACE) is still in ${file}: remove "vexp" from its mcpServers by hand\n`);
4254
+ continue;
4255
+ }
4256
+ if (!viaLink && antigravityHoldsNothing(cut.text) && !globalFileIs(file)) {
4257
+ fs.unlinkSync(file);
4258
+ // The backup goes only when it is vexp's too: up to 3.3.9 the CLI
4259
+ // backed the file up before each write that also dropped any server
4260
+ // named vexp-*, so the copy can be the only one left of the user's
4261
+ // own server (review, 2026-11-07, round 5).
4262
+ const backup = `${file}.vexp-bak`;
4263
+ if (holdsNothingButVexp(backup))
4264
+ fs.rmSync(backup, { force: true });
4265
+ removeDirIfEmpty(path.dirname(file));
4266
+ deleted.push(file);
4267
+ continue;
4268
+ }
4269
+ backupConfig(file);
4270
+ fs.writeFileSync(file, cut.text, cut.encoding);
4271
+ cleaned.push(file);
4272
+ }
4273
+ catch {
4274
+ /* best-effort: what is left there, the doctor names */
4275
+ }
2021
4276
  }
2022
- try {
2023
- fs.writeFileSync(p, JSON.stringify(read.data, null, 2), "utf-8");
4277
+ for (const file of cleaned) {
4278
+ process.stdout.write(` - Removed vexp's old Antigravity entry (no VEXP_WORKSPACE): ${file}\n`);
2024
4279
  }
2025
- catch { /* best effort */ }
2026
- return "stripped";
4280
+ for (const file of deleted) {
4281
+ process.stdout.write(` - Deleted ${file}: it held only vexp's old Antigravity entry, ${antigravityLegacyHarm(os.homedir())}\n`);
4282
+ }
4283
+ return [...cleaned, ...deleted];
2027
4284
  }
2028
4285
  /**
2029
- * True inside WSL. `procVersion` is injectable for tests; when given, it is
2030
- * the only thing consulted.
4286
+ * Why a 1.x file that held only vexp's entry is deleted, as the doctor's
4287
+ * note on it says (agLegacyNote): once ~/.gemini/config/.migrated is there,
4288
+ * Antigravity never moves the file again, and "its first start moves that
4289
+ * file over the global one" contradicted the doctor run just before
4290
+ * (review, 2026-11-07, round 5). Lockstep with the extension.
2031
4291
  */
2032
- export function isWsl(procVersion) {
2033
- if (procVersion !== undefined)
2034
- return /microsoft/i.test(procVersion);
2035
- if (process.platform !== "linux")
2036
- return false;
2037
- if (process.env.WSL_DISTRO_NAME)
2038
- return true;
4292
+ export function antigravityLegacyHarm(home) {
4293
+ return fs.existsSync(path.join(home, ".gemini", "config", ".migrated"))
4294
+ ? "in a file current Antigravity builds no longer read"
4295
+ : "and Antigravity's first start moves that file over ~/.gemini/config/mcp_config.json, replacing the servers there";
4296
+ }
4297
+ /**
4298
+ * The text of a JSON(C) file with vexp's member of `key` cut out, and the
4299
+ * encoding to write it back in; undefined when it cannot be cut so.
4300
+ * Strict UTF-8 JSON is rewritten as JSON; anything else loses only vexp's
4301
+ * member (removeJsoncMember), so comments stay. A file that is not UTF-8
4302
+ * (an ANSI file PowerShell 5.1's Set-Content writes) is read one latin1
4303
+ * character per byte and written back the same way: decoded as UTF-8, the
4304
+ * rewrite saved every non-ASCII byte of the user's other servers as U+FFFD
4305
+ * (review, 2026-11-07, round 5). JSON's own syntax is ASCII, so the cut
4306
+ * finds the same members, and every byte outside vexp's goes back as it
4307
+ * was. `dropEmpty`: the key goes too when vexp was all it held (strict
4308
+ * JSON only). Lockstep with the extension.
4309
+ */
4310
+ export function withoutVexpMember(bytes, key, dropEmpty = false) {
4311
+ let raw;
2039
4312
  try {
2040
- return /microsoft/i.test(fs.readFileSync("/proc/version", "utf-8"));
4313
+ raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
2041
4314
  }
2042
4315
  catch {
2043
- return false;
4316
+ const text = removeJsoncMember(bytes.toString("latin1"), key, "vexp");
4317
+ return text === undefined ? undefined : { text, encoding: "latin1", strict: false };
4318
+ }
4319
+ try {
4320
+ const data = JSON.parse(raw);
4321
+ if (!asPlainObject(data) || !asPlainObject(data[key]))
4322
+ return undefined;
4323
+ delete data[key]["vexp"];
4324
+ if (dropEmpty && Object.keys(data[key]).length === 0)
4325
+ delete data[key];
4326
+ return { text: JSON.stringify(data, null, 2) + "\n", encoding: "utf-8", strict: true };
4327
+ }
4328
+ catch {
4329
+ const text = removeJsoncMember(raw, key, "vexp");
4330
+ return text === undefined ? undefined : { text, encoding: "utf-8", strict: false };
2044
4331
  }
2045
4332
  }
2046
- function shellQuote(s) {
2047
- return `'${s.replace(/'/g, `'\\''`)}'`;
4333
+ /** An MCP config that holds vexp's server and nothing else, or nothing at all. Lockstep with the extension's holdsNothingButVexp. */
4334
+ function holdsNothingButVexp(file) {
4335
+ if (!fs.existsSync(file))
4336
+ return false;
4337
+ const read = readJsonConfigSafe(file);
4338
+ if (!read.ok)
4339
+ return false;
4340
+ const servers = read.data.mcpServers;
4341
+ if (Object.keys(read.data).some((k) => k !== "mcpServers"))
4342
+ return false;
4343
+ if (servers === undefined)
4344
+ return true;
4345
+ if (!asPlainObject(servers))
4346
+ return false;
4347
+ return Object.entries(servers).every(([name, entry]) => name === "vexp" && isVexpWrittenEntry(entry));
2048
4348
  }
2049
4349
  /**
2050
- * The Windows-side Cascade entry for a project that lives in WSL, filled in
2051
- * with the real paths, ready to paste. Cascade launches MCP servers on the
2052
- * Windows side even when the workspace is a WSL folder, and reads
2053
- * %USERPROFILE%\.codeium\windsurf\mcp_config.json there — never the WSL home
2054
- * `vexp setup` can write to. Environment variables in the entry do not cross
2055
- * wsl.exe, so VEXP_WORKSPACE is set on the command line inside WSL; `bash -lc`
2056
- * puts a version-managed node on PATH. We do not write Windows files from WSL
2057
- * (see the Windows + WSL2 docs) — we hand the user the exact entry instead.
4350
+ * removeDeadAntigravityEntries at the end of every setup, whichever agents
4351
+ * it sets up, as the extension does at every start (repairMachineEntries).
4352
+ * Run only with Antigravity chosen, it left a CLI-only machine the 1.x file
4353
+ * 3.3.9 wrote, which the first start of the Antigravity IDE or of its VS
4354
+ * Code extension moves over the user's global servers, while the doctor
4355
+ * called it harmless (review, 2026-11-07). The entries are vexp's own and
4356
+ * named no project. Never thrown: setup never fails on it.
2058
4357
  */
2059
- export function windsurfWslBridgeNote(workspaceRoot, binaryPath, mcpServerPath, distro = process.env.WSL_DISTRO_NAME ?? "<your distro>") {
2060
- const inner = mcpServerPath && fs.existsSync(mcpServerPath)
2061
- ? `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(resolveNodeCommand())} ${shellQuote(mcpServerPath)}`
2062
- : `VEXP_WORKSPACE=${shellQuote(workspaceRoot)} exec ${shellQuote(binaryPath)} mcp`;
2063
- const entry = {
2064
- mcpServers: {
2065
- vexp: {
2066
- command: "wsl.exe",
2067
- args: ["-d", distro, "--", "bash", "-lc", inner],
2068
- alwaysAllow: VEXP_TOOLS,
2069
- },
2070
- },
2071
- };
2072
- return [
2073
- "Windsurf on Windows, project in WSL: Cascade starts MCP servers on the WINDOWS side and reads",
2074
- "%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json there — the entry vexp just wrote in your WSL home is not the one it reads.",
2075
- "(Devin Desktop Next, the beta build, keeps its own copy: %USERPROFILE%\\.codeium\\windsurf-next\\mcp_config.json — same entry.)",
2076
- "Paste this on the Windows side (Windsurf: Settings → Cascade → MCP → view raw config), then quit and reopen Windsurf:",
2077
- JSON.stringify(entry, null, 2),
2078
- "Devin Local reads .devin/mcp_config.json from the project (written). If it runs on the Windows side as well, the same entry goes in %APPDATA%\\devin\\mcp_config.json.",
2079
- "Docs: https://vexp.dev/docs#windows-wsl",
2080
- ].join("\n");
4358
+ function removeDeadAntigravityEntriesAtSetup() {
4359
+ try {
4360
+ removeDeadAntigravityEntries();
4361
+ }
4362
+ catch {
4363
+ /* best-effort: what is left there, the doctor names */
4364
+ }
4365
+ }
4366
+ /** A JSON(C) text whose data is nothing: no key, or only an empty mcpServers. */
4367
+ function antigravityHoldsNothing(text) {
4368
+ try {
4369
+ const data = parseJsonc(text);
4370
+ if (!asPlainObject(data))
4371
+ return false;
4372
+ return Object.keys(data).every((k) => k === "mcpServers") && (data.mcpServers === undefined || (asPlainObject(data.mcpServers) && Object.keys(data.mcpServers).length === 0));
4373
+ }
4374
+ catch {
4375
+ return false;
4376
+ }
2081
4377
  }
2082
- export function configureAntigravityGlobal(binaryPath, mcpServerPath) {
2083
- const cfgDir = path.join(os.homedir(), ".gemini", "antigravity");
4378
+ /** Is ~/.gemini/config/mcp_config.json this file, by a link of its own? Then the file is not deleted from under it. */
4379
+ function globalFileIs(file) {
2084
4380
  try {
2085
- fs.mkdirSync(cfgDir, { recursive: true });
4381
+ return fs.realpathSync(antigravityGlobalMcpPath(os.homedir())) === file;
2086
4382
  }
2087
4383
  catch {
2088
4384
  return false;
2089
4385
  }
2090
- return writeMcpConfig(path.join(cfgDir, "mcp_config.json"), binaryPath, undefined, mcpServerPath, undefined);
2091
4386
  }
4387
+ /** Remove `dir` when it is empty; anything else is left as it is. */
4388
+ function removeDirIfEmpty(dir) {
4389
+ try {
4390
+ if (fs.readdirSync(dir).length === 0)
4391
+ fs.rmdirSync(dir);
4392
+ }
4393
+ catch {
4394
+ /* absent, or not ours to remove */
4395
+ }
4396
+ }
4397
+ /**
4398
+ * Write vexp's entry into an `mcpServers` file. A file inside the workspace
4399
+ * root, pinned to it (Cursor, Kiro, Trae, Firebase Studio, .devin, ZCode's
4400
+ * compatibility file), is checked first: through a link out of the project,
4401
+ * or to nothing, it is left as it is (projectWriteLanding). The machine-wide
4402
+ * Windsurf files go through writeMcpServersFile, unchecked.
4403
+ */
2092
4404
  export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot,
2093
4405
  // Key under which the auto-approve tool list is written. Cursor/Windsurf use
2094
4406
  // `alwaysAllow`; Kiro uses `autoApprove` (a `.kiro/settings/mcp.json` field).
2095
4407
  approveKey = "alwaysAllow") {
4408
+ if (pinnedProjectFile(mcpConfigPath, workspaceRoot) && projectWriteRefused(mcpConfigPath, workspaceRoot))
4409
+ return false;
4410
+ return writeMcpServersFile(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot, approveKey);
4411
+ }
4412
+ function writeMcpServersFile(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot, approveKey) {
2096
4413
  const read = readJsonConfigSafe(mcpConfigPath);
2097
4414
  if (!read.ok) {
2098
4415
  warnUnparseable(mcpConfigPath);
2099
4416
  return false;
2100
4417
  }
2101
4418
  const existing = read.data;
4419
+ if (containerMisshapen(existing, "mcpServers"))
4420
+ return leftUntouched(mcpConfigPath, "mcpServers");
2102
4421
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
2103
4422
  const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
2104
4423
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -2108,6 +4427,18 @@ approveKey = "alwaysAllow") {
2108
4427
  const currentCmd = previousVexp?.["command"];
2109
4428
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
2110
4429
  const prevEnv = previousVexp?.env;
4430
+ // A project file committed to the repository, pinned by another checkout:
4431
+ // the pin stays, and the command and args change only when the vexp
4432
+ // install they name is gone (committedPin). Machine-wide files are never this.
4433
+ const kept = committedPin(mcpConfigPath, workspaceRoot, envPin(prevEnv));
4434
+ if (kept) {
4435
+ if (!staleVexpInstall(previousVexp))
4436
+ return noteCommittedPin(mcpConfigPath, kept);
4437
+ beforeServers["vexp"] = { ...previousVexp, command: targetCmd, args: targetArgs };
4438
+ backupConfig(mcpConfigPath);
4439
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), "utf-8");
4440
+ return true;
4441
+ }
2111
4442
  const envMatches = JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
2112
4443
  const identical = currentCmd === targetCmd &&
2113
4444
  prevArgs !== undefined &&
@@ -2176,6 +4507,12 @@ export function zcodeMcpTarget(workspaceRoot) {
2176
4507
  if (fs.existsSync(native))
2177
4508
  return native;
2178
4509
  const compat = path.join(workspaceRoot, ".agents", "mcp.json");
4510
+ // A compatibility file reached through a link out of the project is not
4511
+ // read: nothing is read through such a link. It may hold the project's
4512
+ // servers, so it is the target, which the writer leaves as it is and
4513
+ // names, rather than a native file that would unplug them.
4514
+ if (fs.existsSync(compat) && !projectWriteLanding(compat, workspaceRoot).ok)
4515
+ return compat;
2179
4516
  const read = readJsonConfigSafe(compat);
2180
4517
  if (read.existed && read.ok) {
2181
4518
  const servers = read.data.mcpServers;
@@ -2195,20 +4532,43 @@ export function configureZcodeMcp(workspaceRoot, binaryPath) {
2195
4532
  const rel = path.relative(workspaceRoot, target).replace(/\\/g, "/");
2196
4533
  if (rel === ".agents/mcp.json") {
2197
4534
  // The compatibility file has the mcpServers shape the shared writer
2198
- // knows; withholding mcpServerPath selects the binary transport.
4535
+ // knows; withholding mcpServerPath selects the binary transport. The
4536
+ // shared writer checks where the write lands.
2199
4537
  return writeMcpConfig(target, binaryPath, undefined, undefined, workspaceRoot) ? rel : null;
2200
4538
  }
4539
+ if (projectWriteRefused(target, workspaceRoot))
4540
+ return null;
2201
4541
  const read = readJsonConfigSafe(target);
2202
4542
  if (!read.ok) {
2203
4543
  warnUnparseable(target);
2204
4544
  return null;
2205
4545
  }
2206
4546
  const cfg = read.data;
4547
+ // The key the entry goes under is not an object: left untouched and
4548
+ // reported (leftUntouched), as the shared writer does for the
4549
+ // compatibility file above.
4550
+ const misshapen = containerMisshapen(cfg, "mcp")
4551
+ ? "mcp"
4552
+ : asPlainObject(cfg.mcp) && containerMisshapen(cfg.mcp, "servers") && "mcp.servers";
4553
+ if (misshapen) {
4554
+ leftUntouched(target, misshapen);
4555
+ return null;
4556
+ }
2207
4557
  const mcp = (cfg.mcp && typeof cfg.mcp === "object" ? cfg.mcp : {});
2208
4558
  const servers = (mcp.servers && typeof mcp.servers === "object" ? mcp.servers : {});
2209
4559
  const targetArgs = ["mcp"];
2210
4560
  const targetEnv = { VEXP_WORKSPACE: workspaceRoot };
2211
4561
  const prev = servers["vexp"];
4562
+ // Committed and pinned by another checkout: see committedPin.
4563
+ const kept = committedPin(target, workspaceRoot, envPin(prev?.env));
4564
+ if (kept) {
4565
+ if (!staleVexpInstall(prev))
4566
+ return noteCommittedPin(target, kept) || null;
4567
+ servers["vexp"] = { ...prev, command: binaryPath, args: targetArgs };
4568
+ backupConfig(target);
4569
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
4570
+ return rel;
4571
+ }
2212
4572
  const envMatches = JSON.stringify(prev?.env) === JSON.stringify(targetEnv);
2213
4573
  const identical = prev?.command === binaryPath &&
2214
4574
  JSON.stringify(prev?.args) === JSON.stringify(targetArgs) &&
@@ -2600,23 +4960,269 @@ export function configureCodexGlobal(binaryPath, _mcpServerPath, workspaceRoot,
2600
4960
  atomicWriteFileSync(configPath, content);
2601
4961
  return true;
2602
4962
  }
4963
+ /**
4964
+ * The VEXP_WORKSPACE vexp's .vscode/mcp.json entry gets. When the folder that
4965
+ * holds `.vscode/mcp.json` is the workspace root, "${workspaceFolder}": every
4966
+ * VS Code that reads the file (1.99+) expands it to that folder before it
4967
+ * starts the server, or does not start it; where the literal reaches a
4968
+ * server anyway (a .mcp.json entry, a client that expands nothing), both MCP
4969
+ * servers take a value still holding "${" for no pin, and the session's
4970
+ * folder decides. No "cwd" is written: VS Code 1.101.1+ starts the server
4971
+ * in that folder already, 1.99-1.100 ignore the key, and 1.139+ refuses to
4972
+ * migrate an entry that has one to .mcp.json. The absolute root went into a file teams commit, so
4973
+ * every checkout and worktree that ran a setup repinned it to itself, and
4974
+ * main repinned it back after the merge (field report, 2026-11-07). Only a
4975
+ * file in a subfolder of the root (a multi-root window with the root not
4976
+ * listed: the extension's case; the CLI writes at the root) keeps the
4977
+ * absolute root, since the index lives there. Lockstep with the extension.
4978
+ */
4979
+ export function vsCodeMcpPin(p, workspaceRoot) {
4980
+ const holder = path.dirname(path.dirname(path.resolve(p)));
4981
+ const atRoot = path.basename(path.dirname(path.resolve(p))) === ".vscode" && sameFolder(holder, path.resolve(workspaceRoot));
4982
+ return atRoot ? "${workspaceFolder}" : workspaceRoot;
4983
+ }
4984
+ /**
4985
+ * The server named vexp, in any letter case, in `<root>/.mcp.json`: the
4986
+ * portable file VS Code 1.139+ migrates .vscode/mcp.json into and Claude
4987
+ * Code reads as its project scope. Read as JSON, JSONC or with a BOM, and
4988
+ * through a link, as VS Code and Claude Code read it: a .mcp.json linked
4989
+ * from a shared folder still defines vexp for them, and a vexp entry added
4990
+ * to .vscode/mcp.json would turn that one off. Writing through such a link
4991
+ * is refused all the same (maintainProjectMcpJson). Nothing from a file
4992
+ * whose mcpServers is not an object. undefined when there is no such
4993
+ * server. Lockstep with the extension's dotMcpJsonVexp.
4994
+ */
4995
+ export function projectMcpJsonVexp(workspaceRoot) {
4996
+ const file = path.join(workspaceRoot, ".mcp.json");
4997
+ if (!fs.existsSync(file))
4998
+ return undefined;
4999
+ const read = readJsonConfigSafe(file);
5000
+ if (!read.ok)
5001
+ return undefined;
5002
+ const servers = read.data.mcpServers;
5003
+ if (!asPlainObject(servers))
5004
+ return undefined;
5005
+ const name = Object.keys(servers).find((k) => k.toLowerCase() === "vexp");
5006
+ return name === undefined ? undefined : { file, name, entry: servers[name] };
5007
+ }
5008
+ /**
5009
+ * The command and args a vexp entry gets when the install it names is gone
5010
+ * (staleVexpInstall), in the shape it has: an entry that runs a bundled
5011
+ * mcp-server.cjs gets this node and this bundle in place of the old one,
5012
+ * its other arguments kept; one that runs vexp-core gets this binary, its
5013
+ * arguments (`mcp`, a `--workspace`) kept. undefined for any other shape.
5014
+ */
5015
+ function repairedCommand(entry, binaryPath, mcpServerPath) {
5016
+ if (!asPlainObject(entry) || typeof entry.command !== "string")
5017
+ return undefined;
5018
+ const args = Array.isArray(entry.args) ? entry.args : [];
5019
+ const isBundle = (a) => typeof a === "string" && /mcp-server\.cjs$/.test(a);
5020
+ if (args.some(isBundle)) {
5021
+ if (!mcpServerPath || !fs.existsSync(mcpServerPath))
5022
+ return { command: binaryPath, args: ["mcp"] };
5023
+ return { command: resolveNodeCommand(), args: args.map((a) => (isBundle(a) ? mcpServerPath : a)) };
5024
+ }
5025
+ const base = (entry.command.split(/[\\/]/).pop() ?? "").toLowerCase();
5026
+ if (base.startsWith("vexp-core"))
5027
+ return { command: binaryPath, args };
5028
+ return undefined;
5029
+ }
5030
+ /**
5031
+ * W4: keep the vexp server of `<root>/.mcp.json` runnable. When its command
5032
+ * or args point into a vexp install that is gone (staleVexpInstall), only
5033
+ * those two are changed, in the text (upsertJsoncMember): its env, type,
5034
+ * tools and everything else in the file stay as they are, and a pin is
5035
+ * never touched. Never creates the file; a link out of the project or to
5036
+ * nothing is left alone (projectWriteLanding), and so is a file that is
5037
+ * not UTF-8. Returns true when the file was written.
5038
+ */
5039
+ export function maintainProjectMcpJson(workspaceRoot, binaryPath, mcpServerPath) {
5040
+ const found = projectMcpJsonVexp(workspaceRoot);
5041
+ if (!found || !staleVexpInstall(found.entry))
5042
+ return false;
5043
+ const repaired = repairedCommand(found.entry, binaryPath, mcpServerPath);
5044
+ if (!repaired)
5045
+ return false;
5046
+ const entry = found.entry;
5047
+ if (entry.command === repaired.command && JSON.stringify(entry.args) === JSON.stringify(repaired.args))
5048
+ return false;
5049
+ if (projectWriteRefused(found.file, workspaceRoot))
5050
+ return false;
5051
+ let raw;
5052
+ try {
5053
+ raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(found.file));
5054
+ }
5055
+ catch {
5056
+ return false;
5057
+ }
5058
+ let text = upsertJsoncMember(raw, ["mcpServers", found.name, "command"], repaired.command);
5059
+ if (text !== undefined)
5060
+ text = upsertJsoncMember(text, ["mcpServers", found.name, "args"], repaired.args);
5061
+ if (text === undefined) {
5062
+ // The text could not be edited safely: the whole file, as the writers
5063
+ // here always did.
5064
+ const data = parseJsonc(raw);
5065
+ data.mcpServers[found.name] = { ...data.mcpServers[found.name], command: repaired.command, args: repaired.args };
5066
+ text = JSON.stringify(data, null, 2) + "\n";
5067
+ }
5068
+ backupConfig(found.file);
5069
+ fs.writeFileSync(found.file, text, "utf-8");
5070
+ return true;
5071
+ }
5072
+ /**
5073
+ * W4: `<root>/.mcp.json` defines vexp, so .vscode/mcp.json gets no vexp
5074
+ * entry: VS Code would start that one and turn the .mcp.json entry off (a
5075
+ * name in .vscode/mcp.json wins), and that is the one the user kept. vexp's
5076
+ * own entry (isVexpWrittenEntry; never one the user wrote, never one pinned
5077
+ * to another folder) goes from .vscode/mcp.json, cut out of its text
5078
+ * (removeJsoncMember), and the file goes only when nothing at all is left
5079
+ * in it, no comment either, and then its folder when that is empty. A file
5080
+ * committed to the repository is left as it is (committedCleanupBlocked),
5081
+ * and so is one that leads out of the project through a link
5082
+ * (projectWriteLanding). Says MCP_JSON_DEFINES_VEXP once. Lockstep with
5083
+ * the extension's leaveVexpOutOfVsCodeMcp.
5084
+ */
5085
+ function leaveVexpOutOfVsCodeMcp(p, workspaceRoot) {
5086
+ try {
5087
+ // A .vscode/mcp.json that is itself a link loses vexp's member in its
5088
+ // target, and the link stays (removeProjectMcpEntryThroughLink, the
5089
+ // round-5 rule): unlinked when nothing was left, the repository's link
5090
+ // was gone. The extension's removeProjectMcpEntry does the same.
5091
+ let link = false;
5092
+ try {
5093
+ link = fs.lstatSync(p).isSymbolicLink();
5094
+ }
5095
+ catch {
5096
+ return; // absent
5097
+ }
5098
+ if (link) {
5099
+ removeProjectMcpEntryThroughLink(p, "servers", workspaceRoot, "GitHub Copilot");
5100
+ return;
5101
+ }
5102
+ // Nothing is read through a folder that leads out of the project.
5103
+ if (!fs.existsSync(p) || !projectWriteLanding(p, workspaceRoot).ok)
5104
+ return;
5105
+ const read = readJsonConfigSafe(p);
5106
+ const servers = read.ok ? read.data.servers : undefined;
5107
+ if (!asPlainObject(servers) || !isVexpWrittenEntry(servers["vexp"]))
5108
+ return;
5109
+ const pin = envPin(servers["vexp"].env);
5110
+ // "${workspaceFolder}" names whichever checkout reads the file: this one.
5111
+ if (typeof pin === "string" && !pin.includes("${") && !sameFolder(pin, workspaceRoot)) {
5112
+ noteSetup(`the vexp entry in ${p} is pinned to ${pin}, not to this workspace: left as it is`);
5113
+ return;
5114
+ }
5115
+ if (committedCleanupBlocked([p], "GitHub Copilot"))
5116
+ return;
5117
+ const bytes = fs.readFileSync(p);
5118
+ let raw;
5119
+ let encoding = "utf-8";
5120
+ try {
5121
+ raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
5122
+ }
5123
+ catch {
5124
+ raw = bytes.toString("latin1");
5125
+ encoding = "latin1";
5126
+ }
5127
+ const cut = removeJsoncMember(raw, "servers", "vexp");
5128
+ if (cut === undefined) {
5129
+ noteSetup(`vexp's entry in ${p} could not be cut out of the text: left as it is`);
5130
+ return;
5131
+ }
5132
+ // Deleted with no backup when nothing is left, not even a comment: it
5133
+ // held vexp's entry and nothing else.
5134
+ const bare = cut.replace(/^\uFEFF/, "").replace(/\s+/g, "");
5135
+ if (bare === "{}" || bare === '{"servers":{}}') {
5136
+ fs.unlinkSync(p);
5137
+ removeDirIfEmpty(path.dirname(p));
5138
+ }
5139
+ else {
5140
+ backupConfig(p);
5141
+ fs.writeFileSync(p, cut, encoding);
5142
+ }
5143
+ }
5144
+ catch {
5145
+ /* best-effort: what is left there, the doctor names */
5146
+ }
5147
+ finally {
5148
+ noteSetup(MCP_JSON_DEFINES_VEXP);
5149
+ }
5150
+ }
5151
+ /**
5152
+ * GitHub Copilot's MCP setup, as `vexp setup` does it: `<root>/.mcp.json`'s
5153
+ * vexp server kept runnable (maintainProjectMcpJson), and
5154
+ * `<root>/.vscode/mcp.json` written (writeVsCodeMcpConfig), which gets no
5155
+ * vexp entry when .mcp.json has one that would serve this root (M2). Returns
5156
+ * the files written, relative to the root.
5157
+ */
5158
+ export function configureCopilotMcp(workspaceRoot, binaryPath, mcpServerPath) {
5159
+ const out = [];
5160
+ if (maintainProjectMcpJson(workspaceRoot, binaryPath, mcpServerPath))
5161
+ out.push(".mcp.json");
5162
+ if (writeVsCodeMcpConfig(path.join(workspaceRoot, ".vscode", "mcp.json"), binaryPath, mcpServerPath, workspaceRoot))
5163
+ out.push(".vscode/mcp.json");
5164
+ return out;
5165
+ }
2603
5166
  /**
2604
5167
  * Write .vscode/mcp.json for GitHub Copilot.
2605
5168
  * VS Code uses "servers" (not "mcpServers") + "type": "stdio".
5169
+ *
5170
+ * Only vexp's member is edited (memberEditedText): the user's comments, BOM,
5171
+ * key order, layout and last newline stay, where every write used to
5172
+ * re-serialise the whole file. Pinned with vsCodeMcpPin. A file committed to
5173
+ * the repository whose entry another checkout pinned keeps that pin
5174
+ * (committedPin); its command and args change only when the vexp install
5175
+ * they name is gone. Nothing is added when `<root>/.mcp.json` defines vexp
5176
+ * and that entry would serve this root from VS Code (leaveVexpOutOfVsCodeMcp,
5177
+ * dotMcpJsonMiss); otherwise vexp's entry is written as with no .mcp.json,
5178
+ * and the reason is a setup note (dotMcpJsonKeptLine).
2606
5179
  */
2607
5180
  export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
5181
+ // .mcp.json at the root already defines vexp: nothing is added here, and
5182
+ // vexp's own entry goes (W4). Before the link check: the removal makes its own.
5183
+ // Only when that entry would serve this root from VS Code (M2): one pinned
5184
+ // to another folder, or whose command does not start here, would leave
5185
+ // Copilot with another checkout's index or none, so vexp's entry is
5186
+ // written as with no .mcp.json at all, and VS Code uses it instead.
5187
+ const dot = workspaceRoot && path.resolve(path.dirname(path.dirname(p))) === path.resolve(workspaceRoot) ? projectMcpJsonVexp(workspaceRoot) : undefined;
5188
+ if (dot && workspaceRoot) {
5189
+ const miss = dotMcpJsonMiss(dot.entry, workspaceRoot);
5190
+ if (!miss) {
5191
+ leaveVexpOutOfVsCodeMcp(p, workspaceRoot);
5192
+ return false;
5193
+ }
5194
+ noteSetup(dotMcpJsonKeptLine(workspaceRoot, miss));
5195
+ }
5196
+ // Pinned to the project: a link out of it, or to nothing, is left as it is.
5197
+ if (pinnedProjectFile(p, workspaceRoot) && projectWriteRefused(p, workspaceRoot))
5198
+ return false;
2608
5199
  const read = readJsonConfigSafe(p);
2609
5200
  if (!read.ok) {
2610
5201
  warnUnparseable(p);
2611
5202
  return false;
2612
5203
  }
2613
5204
  const existing = read.data;
5205
+ if (containerMisshapen(existing, "servers"))
5206
+ return leftUntouched(p, "servers");
2614
5207
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
2615
5208
  const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
2616
5209
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
2617
- const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
5210
+ const pin = workspaceRoot ? vsCodeMcpPin(p, workspaceRoot) : undefined;
5211
+ const targetEnv = pin ? { VEXP_WORKSPACE: pin } : undefined;
2618
5212
  const beforeServers = existing.servers;
2619
5213
  const previousVexp = beforeServers?.["vexp"];
5214
+ // Committed, and another checkout's pin: that pin stays (W3).
5215
+ const kept = committedPin(p, workspaceRoot, envPin(previousVexp?.env));
5216
+ if (kept) {
5217
+ if (!staleVexpInstall(previousVexp))
5218
+ return noteCommittedPin(p, kept);
5219
+ const entry = { ...previousVexp, command: targetCmd, args: targetArgs };
5220
+ beforeServers["vexp"] = entry;
5221
+ backupConfig(p);
5222
+ const edited = memberEditedText(p, existing, "servers", "vexp", entry);
5223
+ fs.writeFileSync(p, edited.text, edited.encoding);
5224
+ return true;
5225
+ }
2620
5226
  const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
2621
5227
  const envMatches = JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
2622
5228
  const identical = previousVexp?.["command"] === targetCmd &&
@@ -2634,18 +5240,41 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
2634
5240
  // pin, so a moved or renamed project is repinned normally.
2635
5241
  if (removed.length === 0 && envMatches && adoptableEntry(previousVexp))
2636
5242
  return false;
5243
+ // A working entry pinned to this folder's absolute path. vexp's own (any
5244
+ // vexp install's, ours from before 3.4 included) moves its pin to
5245
+ // "${workspaceFolder}", once, command and args kept, so this setup and the
5246
+ // other install do not take the entry from each other. One the user wrote
5247
+ // (their own launcher: isVexpWrittenEntry false) is theirs and is left as
5248
+ // it is, as before 3.4: W1 moves vexp-written entries only, and the
5249
+ // rewrite was a diff in a file vexp does not own (review, 2026-11-07).
5250
+ const pinnedHere = removed.length === 0 &&
5251
+ pin === "${workspaceFolder}" &&
5252
+ typeof envPin(previousVexp?.env) === "string" &&
5253
+ sameFolder(envPin(previousVexp?.env), workspaceRoot) &&
5254
+ adoptableEntry(previousVexp);
5255
+ if (pinnedHere && !isVexpWrittenEntry(previousVexp))
5256
+ return false;
5257
+ const movesPin = pinnedHere && isVexpWrittenEntry(previousVexp);
2637
5258
  const servers = existing.servers ?? {};
2638
- servers["vexp"] = {
2639
- type: "stdio",
2640
- command: targetCmd,
2641
- args: targetArgs,
2642
- ...(targetEnv ? { env: targetEnv } : {}),
2643
- };
5259
+ const entry = movesPin
5260
+ ? { ...previousVexp, env: targetEnv }
5261
+ : {
5262
+ type: "stdio",
5263
+ command: targetCmd,
5264
+ args: targetArgs,
5265
+ ...(targetEnv ? { env: targetEnv } : {}),
5266
+ };
5267
+ servers["vexp"] = entry;
2644
5268
  existing.servers = servers;
2645
5269
  fs.mkdirSync(path.dirname(p), { recursive: true });
5270
+ // stripLegacyVexpEntries names each key it took out as "servers.<key>".
5271
+ const dropped = removed.map((r) => r.slice("servers.".length));
5272
+ const edited = read.existed
5273
+ ? memberEditedText(p, existing, "servers", "vexp", entry, dropped)
5274
+ : { text: JSON.stringify(existing, null, 2), encoding: "utf-8" };
2646
5275
  if (read.existed)
2647
5276
  backupConfig(p);
2648
- fs.writeFileSync(p, JSON.stringify(existing, null, 2), "utf-8");
5277
+ fs.writeFileSync(p, edited.text, edited.encoding);
2649
5278
  return true;
2650
5279
  }
2651
5280
  /**
@@ -2706,8 +5335,18 @@ export function opencodeConfigTarget(workspaceRoot) {
2706
5335
  * file is backed up before rewrite. Returns the workspace-relative path written,
2707
5336
  * or null on a no-op / unparseable file.
2708
5337
  */
2709
- /** Cline's globalStorage dirs across OSes and VS Code variants: the marker
2710
- * that Cline is installed, and the parent of its MCP settings file. */
5338
+ /**
5339
+ * Cline's globalStorage dirs across OSes, VS Code variants and the forks
5340
+ * Cline runs in: the marker that Cline is installed, and the parent of its
5341
+ * MCP settings file. A remote window (Remote-WSL, SSH, Dev Containers) runs
5342
+ * Cline on the remote side, whose editor server keeps its storage under
5343
+ * <home>/.vscode-server/data/User (and the forks' servers under their own
5344
+ * folder): with only the desktop folders listed, setup there wrote no
5345
+ * registry and said "no Cline install found" beside a Cline that was
5346
+ * installed, and so did one run in Cursor or Windsurf (review,
5347
+ * 2026-11-07, round 4). The first three stay first, in their old order.
5348
+ * Lockstep with the extension.
5349
+ */
2711
5350
  function clineStorageDirs() {
2712
5351
  const home = os.homedir();
2713
5352
  const base = process.platform === "win32"
@@ -2715,7 +5354,19 @@ function clineStorageDirs() {
2715
5354
  : process.platform === "darwin"
2716
5355
  ? path.join(home, "Library", "Application Support")
2717
5356
  : path.join(home, ".config");
2718
- return ["Code", "Code - Insiders", "VSCodium"].map((variant) => path.join(base, variant, "User", "globalStorage", "saoudrizwan.claude-dev"));
5357
+ const desktop = ["Code", "Code - Insiders", "VSCodium", "Cursor", "Windsurf"].map((variant) => path.join(base, variant, "User"));
5358
+ const remote = [".vscode-server", ".vscode-server-insiders", ".vscodium-server", ".cursor-server", ".windsurf-server"].map((server) => path.join(home, server, "data", "User"));
5359
+ return [...desktop, ...remote].map((user) => path.join(user, "globalStorage", "saoudrizwan.claude-dev"));
5360
+ }
5361
+ /**
5362
+ * Is Cline installed in any VS Code variant (its globalStorage folder is
5363
+ * there)? What noteClineMcpUnreachable is for: a registry left untouched,
5364
+ * one that does not parse, or one that already holds vexp's entry gets no
5365
+ * write either, and "no Cline install found" beside "left untouched"
5366
+ * contradicted it (review, 2026-11-07).
5367
+ */
5368
+ function clineInstalled() {
5369
+ return clineStorageDirs().some((d) => fs.existsSync(d));
2719
5370
  }
2720
5371
  /**
2721
5372
  * Cline: MCP servers live in a MACHINE-GLOBAL file per VS Code variant
@@ -2740,6 +5391,10 @@ export function configureClineMcp(workspaceRoot, binaryPath) {
2740
5391
  continue;
2741
5392
  }
2742
5393
  const cfg = read.data;
5394
+ if (containerMisshapen(cfg, "mcpServers")) {
5395
+ leftUntouched(target, "mcpServers");
5396
+ continue;
5397
+ }
2743
5398
  const servers = cfg.mcpServers ?? {};
2744
5399
  const entry = {
2745
5400
  command: binaryPath,
@@ -2763,17 +5418,34 @@ export function configureClineMcp(workspaceRoot, binaryPath) {
2763
5418
  }
2764
5419
  export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
2765
5420
  const target = opencodeConfigTarget(workspaceRoot);
5421
+ // Pinned to the project: a link out of it, or to nothing, is left as it is.
5422
+ if (projectWriteRefused(target, workspaceRoot))
5423
+ return null;
2766
5424
  const read = readJsonConfigSafe(target);
2767
5425
  if (!read.ok) {
2768
5426
  warnUnparseable(target);
2769
5427
  return null;
2770
5428
  }
2771
5429
  const cfg = read.data;
5430
+ if (containerMisshapen(cfg, "mcp")) {
5431
+ leftUntouched(target, "mcp");
5432
+ return null;
5433
+ }
2772
5434
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
2773
5435
  const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
2774
5436
  const env = { VEXP_WORKSPACE: workspaceRoot };
2775
5437
  const mcp = cfg.mcp ?? {};
2776
5438
  const prev = mcp["vexp"];
5439
+ // Committed and pinned by another checkout: see committedPin.
5440
+ const kept = committedPin(target, workspaceRoot, envPin(prev?.["env"]));
5441
+ if (kept) {
5442
+ if (!staleVexpInstall(prev))
5443
+ return noteCommittedPin(target, kept) || null;
5444
+ mcp["vexp"] = { ...prev, command };
5445
+ backupConfig(target);
5446
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
5447
+ return path.relative(workspaceRoot, target);
5448
+ }
2777
5449
  const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
2778
5450
  const identical = prev?.["type"] === "local" &&
2779
5451
  prev?.["enabled"] === true &&
@@ -2805,17 +5477,34 @@ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
2805
5477
  }
2806
5478
  export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
2807
5479
  const target = kiloConfigTarget(workspaceRoot);
5480
+ // Pinned to the project: a link out of it, or to nothing, is left as it is.
5481
+ if (projectWriteRefused(target, workspaceRoot))
5482
+ return null;
2808
5483
  const read = readJsonConfigSafe(target);
2809
5484
  if (!read.ok) {
2810
5485
  warnUnparseable(target);
2811
5486
  return null;
2812
5487
  }
2813
5488
  const cfg = read.data;
5489
+ if (containerMisshapen(cfg, "mcp")) {
5490
+ leftUntouched(target, "mcp");
5491
+ return null;
5492
+ }
2814
5493
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
2815
5494
  const command = useNode ? [resolveNodeCommand(), mcpServerPath] : [binaryPath, "mcp"];
2816
5495
  const env = { VEXP_WORKSPACE: workspaceRoot };
2817
5496
  const mcp = cfg.mcp ?? {};
2818
5497
  const prev = mcp["vexp"];
5498
+ // Committed and pinned by another checkout: see committedPin.
5499
+ const kept = committedPin(target, workspaceRoot, envPin(prev?.["env"]));
5500
+ if (kept) {
5501
+ if (!staleVexpInstall(prev))
5502
+ return noteCommittedPin(target, kept) || null;
5503
+ mcp["vexp"] = { ...prev, command };
5504
+ backupConfig(target);
5505
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
5506
+ return path.relative(workspaceRoot, target);
5507
+ }
2819
5508
  const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
2820
5509
  const identical = prev?.["type"] === "local" &&
2821
5510
  prev?.["enabled"] === true &&
@@ -2840,12 +5529,29 @@ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
2840
5529
  return path.relative(workspaceRoot, target);
2841
5530
  }
2842
5531
  export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
5532
+ // The CLI pins Zed's entry too (VEXP_WORKSPACE inside `command`): a link
5533
+ // out of the project, or to nothing, is left as it is.
5534
+ if (pinnedProjectFile(p, workspaceRoot) && projectWriteRefused(p, workspaceRoot))
5535
+ return false;
2843
5536
  const read = readJsonConfigSafe(p);
2844
5537
  if (!read.ok) {
2845
5538
  warnUnparseable(p);
2846
5539
  return false;
2847
5540
  }
2848
5541
  const settings = read.data;
5542
+ // context_servers takes the entry; agent.tool_permissions.tools takes the
5543
+ // tools' approvals, and an array or a string there was spread into an
5544
+ // object of its characters.
5545
+ if (containerMisshapen(settings, "context_servers"))
5546
+ return leftUntouched(p, "context_servers");
5547
+ if (containerMisshapen(settings, "agent"))
5548
+ return leftUntouched(p, "agent");
5549
+ const agentIn = asPlainObject(settings.agent) ? settings.agent : {};
5550
+ if (containerMisshapen(agentIn, "tool_permissions"))
5551
+ return leftUntouched(p, "agent.tool_permissions");
5552
+ const tpIn = asPlainObject(agentIn.tool_permissions) ? agentIn.tool_permissions : {};
5553
+ if (containerMisshapen(tpIn, "tools"))
5554
+ return leftUntouched(p, "agent.tool_permissions.tools");
2849
5555
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
2850
5556
  const targetCmd = useNode ? resolveNodeCommand() : binaryPath;
2851
5557
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -2853,6 +5559,17 @@ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
2853
5559
  const beforeCs = settings.context_servers;
2854
5560
  const previousVexp = beforeCs?.["vexp"];
2855
5561
  const curCmd = previousVexp?.["command"];
5562
+ // Committed and pinned by another checkout: see committedPin. Zed nests
5563
+ // the env inside `command`, which keeps it.
5564
+ const kept = committedPin(p, workspaceRoot, envPin(asPlainObject(curCmd) ? curCmd.env : undefined));
5565
+ if (kept) {
5566
+ if (!staleVexpInstall(previousVexp))
5567
+ return noteCommittedPin(p, kept);
5568
+ beforeCs["vexp"] = { ...previousVexp, command: { ...curCmd, path: targetCmd, args: targetArgs } };
5569
+ backupConfig(p);
5570
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
5571
+ return true;
5572
+ }
2856
5573
  // Zed nests env inside `command`, unlike every other writer.
2857
5574
  const envMatches = JSON.stringify(curCmd?.["env"]) === JSON.stringify(targetEnv);
2858
5575
  const identical = curCmd?.["path"] === targetCmd &&
@@ -2908,6 +5625,8 @@ version) {
2908
5625
  return false;
2909
5626
  }
2910
5627
  const config = read.data;
5628
+ if (containerMisshapen(config, "mcpServers"))
5629
+ return leftUntouched(configPath, "mcpServers");
2911
5630
  const beforeServers = config.mcpServers;
2912
5631
  const existing = beforeServers?.["vexp"];
2913
5632
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
@@ -3049,8 +5768,17 @@ function isVexpGuardHookEntry(h) {
3049
5768
  export function uninstallClaudeCodeHook(workspaceRoot) {
3050
5769
  let removed = false;
3051
5770
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
3052
- for (const name of ["vexp-guard.sh", "vexp-guard.cmd"]) {
3053
- const p = path.join(hookDir, name);
5771
+ const scripts = ["vexp-guard.sh", "vexp-guard.cmd"].map((name) => path.join(hookDir, name));
5772
+ const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
5773
+ const read = readJsonConfigSafe(settingsPath);
5774
+ const hooksIn = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
5775
+ const preIn = Array.isArray(hooksIn.PreToolUse) ? hooksIn.PreToolUse : [];
5776
+ const deregisters = read.ok && read.existed && JSON.stringify(placeVexpHook(preIn, isVexpGuardHookEntry, null)) !== JSON.stringify(preIn);
5777
+ // A script or a settings.json committed to the repository: the guard
5778
+ // stays whole, script and entry together (committedCleanupBlocked).
5779
+ if (committedCleanupBlocked([...scripts, deregisters ? settingsPath : undefined], "Claude Code"))
5780
+ return false;
5781
+ for (const p of scripts) {
3054
5782
  if (fs.existsSync(p)) {
3055
5783
  try {
3056
5784
  fs.unlinkSync(p);
@@ -3059,8 +5787,6 @@ export function uninstallClaudeCodeHook(workspaceRoot) {
3059
5787
  catch { /* leave in place */ }
3060
5788
  }
3061
5789
  }
3062
- const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
3063
- const read = readJsonConfigSafe(settingsPath);
3064
5790
  if (read.ok && read.existed) {
3065
5791
  const settings = read.data;
3066
5792
  const hooks = (settings.hooks ?? {});
@@ -3242,7 +5968,8 @@ export function installClaudeCodeSessionContext(workspaceRoot, binaryPath) {
3242
5968
  fs.mkdirSync(hookDir, { recursive: true });
3243
5969
  const script = vexpSessionContextHookScript(binaryPath);
3244
5970
  const existed = fs.existsSync(hookPath);
3245
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf8") === script;
5971
+ // A committed script that bakes another live engine stays (K1).
5972
+ const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSessionContextHookScript);
3246
5973
  if (!scriptIdentical) {
3247
5974
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3248
5975
  }
@@ -3290,7 +6017,9 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
3290
6017
  const gateOn = interventionMode() === "on";
3291
6018
  const existed = fs.existsSync(hookPath);
3292
6019
  const script = vexpStopGateHookScript(binaryPath);
3293
- const scriptIdentical = gateOn && existed && fs.readFileSync(hookPath, "utf8") === script;
6020
+ const scriptIdentical = gateOn && existed && hookScriptCurrent(hookPath, script, vexpStopGateHookScript);
6021
+ if (!gateOn && stopGateRemovalBlocked(hookPath, settingsPath))
6022
+ return null;
3294
6023
  if (gateOn) {
3295
6024
  if (!scriptIdentical) {
3296
6025
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
@@ -3338,6 +6067,26 @@ export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
3338
6067
  }
3339
6068
  return existed ? "updated" : "created";
3340
6069
  }
6070
+ /** The edit-time hook is off: is its script or its settings.json entry committed (see stopGateRemovalBlocked)? */
6071
+ function editHintRemovalBlocked(editHookPath, settingsPath) {
6072
+ const read = readJsonConfigSafe(settingsPath);
6073
+ const hooks = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
6074
+ const post = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
6075
+ const deregisters = post.some((h) => isVexpEditHintHookEntry(h));
6076
+ return committedCleanupBlocked([editHookPath, deregisters ? settingsPath : undefined], "Claude Code");
6077
+ }
6078
+ /**
6079
+ * The gate is off and its script or settings.json entry is committed to
6080
+ * the repository: both stay (committedCleanupBlocked), so the entry never
6081
+ * names a script that is gone.
6082
+ */
6083
+ function stopGateRemovalBlocked(hookPath, settingsPath) {
6084
+ const read = readJsonConfigSafe(settingsPath);
6085
+ const hooks = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
6086
+ const stop = Array.isArray(hooks.Stop) ? hooks.Stop : [];
6087
+ const deregisters = stop.some((h) => isVexpHookEntry(h, "vexp-verify"));
6088
+ return committedCleanupBlocked([hookPath, deregisters ? settingsPath : undefined], "Claude Code");
6089
+ }
3341
6090
  function isVexpEditHintHookEntry(entry) {
3342
6091
  const cmds = JSON.stringify(entry ?? "");
3343
6092
  return cmds.includes("vexp-edit-hint");
@@ -3402,8 +6151,17 @@ function isVexpSearchHookEntry(h) {
3402
6151
  */
3403
6152
  function removeClaudeCodePreToolHooks(workspaceRoot) {
3404
6153
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
3405
- for (const name of ["read-hint", "bash-cap"]) {
3406
- const p = path.join(hookDir, `vexp-${name}.sh`);
6154
+ const scripts = ["read-hint", "bash-cap"].map((name) => path.join(hookDir, `vexp-${name}.sh`));
6155
+ const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
6156
+ const read = readJsonConfigSafe(settingsPath);
6157
+ const ours = (h) => /vexp-(read-hint|bash-cap)/.test(JSON.stringify(h ?? ""));
6158
+ const hooksIn = read.ok && read.existed ? (read.data.hooks ?? {}) : {};
6159
+ const preIn = Array.isArray(hooksIn.PreToolUse) ? hooksIn.PreToolUse : [];
6160
+ const deregisters = read.ok && read.existed && JSON.stringify(placeVexpHook(preIn, ours, null)) !== JSON.stringify(preIn);
6161
+ // Committed to the repository: the hooks stay whole (committedCleanupBlocked).
6162
+ if (committedCleanupBlocked([...scripts, deregisters ? settingsPath : undefined], "Claude Code"))
6163
+ return;
6164
+ for (const p of scripts) {
3407
6165
  if (fs.existsSync(p)) {
3408
6166
  try {
3409
6167
  fs.unlinkSync(p);
@@ -3413,14 +6171,12 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
3413
6171
  }
3414
6172
  }
3415
6173
  }
3416
- const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
3417
- const read = readJsonConfigSafe(settingsPath);
3418
6174
  if (!read.ok || !read.existed)
3419
6175
  return;
3420
6176
  const settings = read.data;
3421
6177
  const hooks = (settings.hooks ?? {});
3422
6178
  const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
3423
- const filtered = placeVexpHook(existing, (h) => /vexp-(read-hint|bash-cap)/.test(JSON.stringify(h ?? "")), null);
6179
+ const filtered = placeVexpHook(existing, ours, null);
3424
6180
  if (JSON.stringify(filtered) === JSON.stringify(existing))
3425
6181
  return;
3426
6182
  const merged = { ...hooks, PreToolUse: filtered };
@@ -3430,13 +6186,15 @@ function removeClaudeCodePreToolHooks(workspaceRoot) {
3430
6186
  backupConfig(settingsPath);
3431
6187
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
3432
6188
  }
3433
- function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher, script) {
6189
+ function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher, bake) {
3434
6190
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
3435
6191
  const hookPath = path.join(hookDir, `vexp-${name}.sh`);
3436
6192
  const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
6193
+ const script = bake(binaryPath);
3437
6194
  fs.mkdirSync(hookDir, { recursive: true });
3438
6195
  const existed = fs.existsSync(hookPath);
3439
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
6196
+ // A committed script that bakes another live engine stays (K1).
6197
+ const scriptIdentical = existed && hookScriptCurrent(hookPath, script, bake);
3440
6198
  if (!scriptIdentical)
3441
6199
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3442
6200
  const read = readJsonConfigSafe(settingsPath);
@@ -3470,10 +6228,10 @@ function installClaudeCodePreToolHook(workspaceRoot, binaryPath, name, matcher,
3470
6228
  return existed ? "updated" : "created";
3471
6229
  }
3472
6230
  export function installClaudeCodeReadHint(workspaceRoot, binaryPath) {
3473
- return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "read-hint", "Read", bakeReadHintHook(binaryPath));
6231
+ return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "read-hint", "Read", bakeReadHintHook);
3474
6232
  }
3475
6233
  export function installClaudeCodeBashCap(workspaceRoot, binaryPath) {
3476
- return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "bash-cap", "Bash", bakeBashCapHook(binaryPath));
6234
+ return installClaudeCodePreToolHook(workspaceRoot, binaryPath, "bash-cap", "Bash", bakeBashCapHook);
3477
6235
  }
3478
6236
  export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
3479
6237
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
@@ -3482,7 +6240,8 @@ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
3482
6240
  const script = vexpSearchHookScript(binaryPath);
3483
6241
  fs.mkdirSync(hookDir, { recursive: true });
3484
6242
  const existed = fs.existsSync(hookPath);
3485
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
6243
+ // A committed script that bakes another live engine stays (K1).
6244
+ const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSearchHookScript);
3486
6245
  if (!scriptIdentical)
3487
6246
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3488
6247
  const read = readJsonConfigSafe(settingsPath);
@@ -3531,20 +6290,26 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
3531
6290
  // InterventionMode above for the measurement.
3532
6291
  const editHookPath = path.join(hookDir, "vexp-edit-hint.sh");
3533
6292
  const couplingOn = interventionMode() === "on";
6293
+ // Off, and the edit hook or its settings.json entry committed to the
6294
+ // repository: both stay as they are (committedCleanupBlocked); the
6295
+ // prompt hook below is set up all the same.
6296
+ const keepEditHook = !couplingOn && editHintRemovalBlocked(editHookPath, settingsPath);
3534
6297
  if (couplingOn) {
3535
6298
  const editScript = bakeEditHintHook(binaryPath);
3536
- const editExisted = fs.existsSync(editHookPath);
3537
- if (!editExisted || fs.readFileSync(editHookPath, "utf-8") !== editScript) {
6299
+ // A committed script that bakes another live engine stays (K1), its
6300
+ // mode included: git tracks the exec bit.
6301
+ if (!hookScriptCurrent(editHookPath, editScript, bakeEditHintHook)) {
3538
6302
  fs.writeFileSync(editHookPath, editScript, { mode: 0o755 });
3539
6303
  }
3540
6304
  try {
3541
- fs.chmodSync(editHookPath, 0o755);
6305
+ if (!hookScriptLeft(editHookPath))
6306
+ fs.chmodSync(editHookPath, 0o755);
3542
6307
  }
3543
6308
  catch {
3544
6309
  /* Windows has no exec bit; Git Bash runs it anyway. */
3545
6310
  }
3546
6311
  }
3547
- else if (fs.existsSync(editHookPath)) {
6312
+ else if (!keepEditHook && fs.existsSync(editHookPath)) {
3548
6313
  // Converge an upgraded workspace without asking anyone to clean up.
3549
6314
  try {
3550
6315
  fs.unlinkSync(editHookPath);
@@ -3554,7 +6319,8 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
3554
6319
  }
3555
6320
  }
3556
6321
  const existed = fs.existsSync(hookPath);
3557
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
6322
+ // A committed script that bakes another live engine stays (K1).
6323
+ const scriptIdentical = existed && hookScriptCurrent(hookPath, script, (bin) => vexpHintHookScript(bin, "claude-code"));
3558
6324
  if (!scriptIdentical) {
3559
6325
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3560
6326
  }
@@ -3589,7 +6355,7 @@ export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
3589
6355
  const existingPost = Array.isArray(hooks.PostToolUse)
3590
6356
  ? hooks.PostToolUse
3591
6357
  : [];
3592
- const filteredPost = placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
6358
+ const filteredPost = keepEditHook ? existingPost : placeVexpHook(existingPost, isVexpEditHintHookEntry, couplingOn
3593
6359
  ? {
3594
6360
  matcher: "Edit|Write|NotebookEdit",
3595
6361
  hooks: [
@@ -3650,7 +6416,8 @@ export function installClaudeCodeSubagentHook(workspaceRoot, binaryPath) {
3650
6416
  }
3651
6417
  fs.mkdirSync(hookDir, { recursive: true });
3652
6418
  const existed = fs.existsSync(hookPath);
3653
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
6419
+ // A committed script that bakes another live engine stays (K1).
6420
+ const scriptIdentical = existed && hookScriptCurrent(hookPath, script, vexpSubagentHookScript);
3654
6421
  if (!scriptIdentical)
3655
6422
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3656
6423
  const settings = read.data;
@@ -3695,7 +6462,8 @@ export function installOpencodeCompressPlugin(workspaceRoot, binaryPath, pluginD
3695
6462
  const content = vexpOpencodeCompressPlugin(binaryPath);
3696
6463
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
3697
6464
  const existed = fs.existsSync(pluginPath);
3698
- if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
6465
+ // A committed plugin that bakes another live engine stays (K1).
6466
+ if (existed && hookScriptCurrent(pluginPath, content, vexpOpencodeCompressPlugin))
3699
6467
  return null;
3700
6468
  fs.writeFileSync(pluginPath, content);
3701
6469
  return existed ? "updated" : "created";
@@ -3705,7 +6473,8 @@ export function installOpencodeHintPlugin(workspaceRoot, binaryPath, pluginDir)
3705
6473
  const content = vexpOpencodeHintPlugin(binaryPath, opencodeFamilyAgent(pluginDir));
3706
6474
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
3707
6475
  const existed = fs.existsSync(pluginPath);
3708
- if (existed && fs.readFileSync(pluginPath, "utf-8") === content)
6476
+ // A committed plugin that bakes another live engine stays (K1).
6477
+ if (existed && hookScriptCurrent(pluginPath, content, (bin) => vexpOpencodeHintPlugin(bin, opencodeFamilyAgent(pluginDir))))
3709
6478
  return null;
3710
6479
  fs.writeFileSync(pluginPath, content);
3711
6480
  return existed ? "updated" : "created";
@@ -3727,7 +6496,9 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3727
6496
  const script = vexpHintHookScript(binaryPath, "codex");
3728
6497
  fs.mkdirSync(dir, { recursive: true });
3729
6498
  const existed = fs.existsSync(hookPath);
3730
- let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
6499
+ // A committed script that bakes another live engine stays (K1), the
6500
+ // batch twin below too.
6501
+ let scriptIdentical = existed && hookScriptCurrent(hookPath, script, (bin) => vexpHintHookScript(bin, "codex"));
3731
6502
  if (!scriptIdentical) {
3732
6503
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
3733
6504
  }
@@ -3738,19 +6509,14 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3738
6509
  // ~0.131, months before the version that requires the nested `hooks` shape
3739
6510
  // we already write). Older builds ignore the field rather than reject the
3740
6511
  // file — the handler is not deny_unknown_fields, unlike the top level.
6512
+ // The entry runs it through `cmd /d /c call` (codexHintEntry).
3741
6513
  const cmdPath = path.join(dir, "vexp-hint.cmd");
3742
- let cmdCommand;
3743
6514
  if (process.platform === "win32") {
3744
6515
  const cmdScript = vexpHintHookCmdScript(binaryPath);
3745
- const cmdExisted = fs.existsSync(cmdPath);
3746
- if (!cmdExisted || fs.readFileSync(cmdPath, "utf-8") !== cmdScript) {
6516
+ if (!hookScriptCurrent(cmdPath, cmdScript, vexpHintHookCmdScript)) {
3747
6517
  fs.writeFileSync(cmdPath, cmdScript);
3748
6518
  scriptIdentical = false;
3749
6519
  }
3750
- // Through `cmd /d /c call`, not the bare path: Codex runs the line in
3751
- // PowerShell by default, where a quoted path is only a string to print
3752
- // (codexWindowsHookCommand has the details).
3753
- cmdCommand = codexWindowsHookCommand(cmdPath);
3754
6520
  }
3755
6521
  let root = {};
3756
6522
  if (fs.existsSync(hooksJsonPath)) {
@@ -3791,16 +6557,27 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3791
6557
  // record over this line (codex-trust.ts codexHookHash), so a change would
3792
6558
  // make every Codex user approve the hook again. The script it runs is
3793
6559
  // POSIX sh all the same, and a new script needs no new approval.
3794
- const ours = {
3795
- hooks: [
3796
- {
3797
- type: "command",
3798
- command: `bash "${hookPath.replace(/\\/g, "/")}"`,
3799
- ...(cmdCommand ? { commandWindows: cmdCommand } : {}),
3800
- timeout: 5,
3801
- },
3802
- ],
3803
- };
6560
+ const ours = codexHintEntry(workspaceRoot);
6561
+ // Committed to the repository and written by another checkout: a vexp
6562
+ // hook whose definition is ours but for that checkout's path is left as it
6563
+ // is, and so is the file: a rewrite only pointed the committed file at
6564
+ // this checkout and made Codex ask for approval again (field report,
6565
+ // 2026-11-07). The hook runs the OTHER checkout's copy of the script, so
6566
+ // it is kept only while that checkout is a checkout of this repository on
6567
+ // this machine and its script is there (sameRepositoryCheckout): after a
6568
+ // clone, a move or a removed worktree it failed at every prompt, and a
6569
+ // rewrite costs one approval instead (review, 2026-11-07).
6570
+ const other = codexHookCheckout(wrapped.find((h) => isVexpHintHookEntry(h)));
6571
+ if (other !== undefined &&
6572
+ !sameFolder(other, upperDriveLetter(workspaceRoot).replace(/\\/g, "/")) &&
6573
+ legacyTopLevel.length === 0 &&
6574
+ codexHookScriptsThere(other, workspaceRoot)) {
6575
+ const theirs = wrapped.find((h) => isVexpHintHookEntry(h));
6576
+ if (JSON.stringify(theirs) === JSON.stringify(codexHintEntry(other)) && isGitTracked(hooksJsonPath)) {
6577
+ noteSetup(committedPinLine(hooksJsonPath, other));
6578
+ return scriptIdentical ? null : existed ? "updated" : "created";
6579
+ }
6580
+ }
3804
6581
  const merged = placeCodexHintEntry(existing, ours);
3805
6582
  const before = JSON.stringify(root);
3806
6583
  codexHooks.UserPromptSubmit = merged;
@@ -3817,6 +6594,48 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
3817
6594
  }
3818
6595
  return existed ? "updated" : "created";
3819
6596
  }
6597
+ /**
6598
+ * Is `other`, the checkout a committed Codex hook names, a checkout of this
6599
+ * repository on this machine with the script the hook runs there
6600
+ * (vexp-hint.sh, and on Windows the vexp-hint.cmd commandWindows names)?
6601
+ * Lockstep with the extension's.
6602
+ */
6603
+ function codexHookScriptsThere(other, workspaceRoot) {
6604
+ if (!sameRepositoryCheckout(other, workspaceRoot))
6605
+ return false;
6606
+ const dir = path.join(other, ".codex");
6607
+ return fs.existsSync(path.join(dir, "vexp-hint.sh")) && (process.platform !== "win32" || fs.existsSync(path.join(dir, "vexp-hint.cmd")));
6608
+ }
6609
+ /**
6610
+ * vexp's UserPromptSubmit group in `<root>/.codex/hooks.json`, as
6611
+ * installCodexHintHook writes it for `root` (the Windows `commandWindows`
6612
+ * twin on Windows only).
6613
+ */
6614
+ function codexHintEntry(root) {
6615
+ const dir = path.join(upperDriveLetter(root), ".codex");
6616
+ const hookPath = path.join(dir, "vexp-hint.sh");
6617
+ // Through `cmd /d /c call`, not the bare path: Codex runs the line in
6618
+ // PowerShell by default, where a quoted path is only a string to print
6619
+ // (codexWindowsHookCommand has the details).
6620
+ const cmdCommand = process.platform === "win32" ? codexWindowsHookCommand(path.join(dir, "vexp-hint.cmd")) : undefined;
6621
+ return {
6622
+ hooks: [
6623
+ {
6624
+ type: "command",
6625
+ command: `bash "${hookPath.replace(/\\/g, "/")}"`,
6626
+ ...(cmdCommand ? { commandWindows: cmdCommand } : {}),
6627
+ timeout: 5,
6628
+ },
6629
+ ],
6630
+ };
6631
+ }
6632
+ /** The checkout a vexp Codex hook group names (`bash "<checkout>/.codex/vexp-hint.sh"`), forward slashes; undefined for anything else. */
6633
+ function codexHookCheckout(group) {
6634
+ const hooks = asPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
6635
+ const command = hooks.length === 1 && asPlainObject(hooks[0]) ? hooks[0].command : undefined;
6636
+ const m = typeof command === "string" ? /^bash "(.+)\/\.codex\/vexp-hint\.sh"$/.exec(command) : null;
6637
+ return m ? m[1] : undefined;
6638
+ }
3820
6639
  /**
3821
6640
  * vexp's UserPromptSubmit group, put where the previous one was.
3822
6641
  *
@@ -3892,10 +6711,12 @@ function removeGuardScript(scriptPath) {
3892
6711
  }
3893
6712
  }
3894
6713
  /** Remove the opencode guard plugin (2.3 A2 default). Both directory
3895
- * spellings are checked — older builds wrote `.opencode/plugin/`. */
6714
+ * spellings are checked — older builds wrote `.opencode/plugin/`. One
6715
+ * committed to the repository stays (committedCleanupBlocked). */
3896
6716
  export function uninstallOpencodePlugin(workspaceRoot) {
3897
- const a = removeGuardScript(path.join(workspaceRoot, ".opencode", "plugins", "vexp-guard.js"));
3898
- const b = removeGuardScript(path.join(workspaceRoot, ".opencode", "plugin", "vexp-guard.js"));
6717
+ const remove = (p) => !committedCleanupBlocked([p], "Opencode") && removeGuardScript(p);
6718
+ const a = remove(path.join(workspaceRoot, ".opencode", "plugins", "vexp-guard.js"));
6719
+ const b = remove(path.join(workspaceRoot, ".opencode", "plugin", "vexp-guard.js"));
3899
6720
  return a || b;
3900
6721
  }
3901
6722
  /**
@@ -4060,9 +6881,15 @@ export function installKiloPlugin(workspaceRoot) {
4060
6881
  * the explicit `plugin` array entry from a root kilo.jsonc when present. */
4061
6882
  export function uninstallKiloPlugin(workspaceRoot) {
4062
6883
  const pluginPath = path.join(workspaceRoot, ".kilo", "plugins", "vexp-guard.js");
4063
- let removed = removeGuardScript(pluginPath);
4064
6884
  const target = kiloConfigTarget(workspaceRoot);
4065
6885
  const read = readJsonConfigSafe(target);
6886
+ // The plugin or the kilo.jsonc that declares it committed to the
6887
+ // repository: both stay (committedCleanupBlocked).
6888
+ const declared = read.ok && read.existed && Array.isArray(read.data.plugin) &&
6889
+ read.data.plugin.some((e) => typeof e === "string" && e.includes("vexp-guard"));
6890
+ if (committedCleanupBlocked([pluginPath, declared ? target : undefined], "Kilo Code"))
6891
+ return false;
6892
+ let removed = removeGuardScript(pluginPath);
4066
6893
  if (read.ok && read.existed) {
4067
6894
  const cfg = read.data;
4068
6895
  const list = Array.isArray(cfg.plugin) ? cfg.plugin : [];
@@ -4097,11 +6924,14 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
4097
6924
  const script = bakeEditHintHook(binaryPath);
4098
6925
  fs.mkdirSync(dir, { recursive: true });
4099
6926
  const existed = fs.existsSync(scriptPath);
4100
- if (!existed || fs.readFileSync(scriptPath, "utf-8") !== script) {
6927
+ // A committed script that bakes another live engine stays (K1), its mode
6928
+ // included: git tracks the exec bit.
6929
+ if (!hookScriptCurrent(scriptPath, script, bakeEditHintHook)) {
4101
6930
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
4102
6931
  }
4103
6932
  try {
4104
- fs.chmodSync(scriptPath, 0o755);
6933
+ if (!hookScriptLeft(scriptPath))
6934
+ fs.chmodSync(scriptPath, 0o755);
4105
6935
  }
4106
6936
  catch {
4107
6937
  /* no exec bit on Windows */
@@ -4115,6 +6945,21 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
4115
6945
  const cfg = read.data;
4116
6946
  const hooks = cfg.hooks ?? {};
4117
6947
  const post = Array.isArray(hooks.postToolUse) ? hooks.postToolUse : [];
6948
+ // Committed to the repository and written by another checkout (the
6949
+ // command names the script by its absolute path): left as it is, as the
6950
+ // Codex hook is (committedPinLine), while that checkout is one of this
6951
+ // repository on this machine and the script it runs is there.
6952
+ const theirs = post.find((e) => asPlainObject(e) && typeof e.command === "string" && e.command.includes("vexp-edit-hint"));
6953
+ const m = asPlainObject(theirs) && typeof theirs.command === "string" ? /^bash "(.+)[\\/]\.cursor[\\/]hooks[\\/]vexp-edit-hint\.sh"$/.exec(theirs.command) : null;
6954
+ if (m &&
6955
+ !sameFolder(m[1], workspaceRoot) &&
6956
+ sameRepositoryCheckout(m[1], workspaceRoot) &&
6957
+ fs.existsSync(path.join(m[1], ".cursor", "hooks", "vexp-edit-hint.sh")) &&
6958
+ JSON.stringify(theirs) === JSON.stringify({ command: `bash "${path.join(m[1], ".cursor", "hooks", "vexp-edit-hint.sh")}"` }) &&
6959
+ isGitTracked(cfgPath)) {
6960
+ noteSetup(committedPinLine(cfgPath, m[1]));
6961
+ return null;
6962
+ }
4118
6963
  const filtered = post.filter((e) => {
4119
6964
  if (!e || typeof e !== "object")
4120
6965
  return true;
@@ -4135,9 +6980,16 @@ export function installCursorEditHint(workspaceRoot, binaryPath) {
4135
6980
  * our preToolUse entry from .cursor/hooks.json. */
4136
6981
  export function uninstallCursorHook(workspaceRoot) {
4137
6982
  const rel = path.join(".cursor", "hooks", "vexp-guard.js");
4138
- let removed = removeGuardScript(path.join(workspaceRoot, rel));
4139
6983
  const cfgPath = path.join(workspaceRoot, ".cursor", "hooks.json");
4140
6984
  const read = readJsonConfigSafe(cfgPath);
6985
+ // The guard or the hooks.json that runs it committed to the repository:
6986
+ // both stay (committedCleanupBlocked).
6987
+ const hooksIn = read.ok && read.existed && asPlainObject(read.data.hooks) ? read.data.hooks : {};
6988
+ const registered = Array.isArray(hooksIn.preToolUse) &&
6989
+ hooksIn.preToolUse.some((e) => asPlainObject(e) && typeof e.command === "string" && e.command.includes("vexp-guard"));
6990
+ if (committedCleanupBlocked([path.join(workspaceRoot, rel), registered ? cfgPath : undefined], "Cursor"))
6991
+ return false;
6992
+ let removed = removeGuardScript(path.join(workspaceRoot, rel));
4141
6993
  if (read.ok && read.existed) {
4142
6994
  const cfg = read.data;
4143
6995
  const hooks = cfg.hooks ?? {};