Browse Source

Remove custom LSP tooling and isolate test runs

Alvin Unreal 3 months ago
parent
commit
694f946dc6

+ 0 - 9
.slim/cartography.json

@@ -82,14 +82,6 @@
     "src/tools/ast-grep/utils.ts": "1dd3b2133c4b8c847a26eea0423bc0b2",
     "src/tools/background.ts": "213c131b32ea7cd8d4678eee4a76987f",
     "src/tools/index.ts": "bc2699bbb686af17c7faf8554879a98f",
-    "src/tools/lsp/client.ts": "4bb958c8e4d72386dc7e4d6f57d6e2d2",
-    "src/tools/lsp/config-store.ts": "e10072482fbb91b9214fa3bf333aa033",
-    "src/tools/lsp/config.ts": "889acfcffb3bb40ee5335a5ababc8ac5",
-    "src/tools/lsp/constants.ts": "06f4618b3937e6aaa6ec4076c2e9ed51",
-    "src/tools/lsp/index.ts": "913a5e5cb7f366a05e618905e65b4900",
-    "src/tools/lsp/tools.ts": "023d712c7a06ab9e43851826114309a0",
-    "src/tools/lsp/types.ts": "7aedfc8809101383d5dd9100bdb60d20",
-    "src/tools/lsp/utils.ts": "eb0243a7dfd75732b0e505cdde38cccf",
     "src/utils/agent-variant.ts": "1ab47427e7e8381ae13f09fd499d8755",
     "src/utils/env.ts": "b76fbfea11c340337f6bdd8a9c87bb69",
     "src/utils/index.ts": "be0d778f15ae5b14f1dfb598611fc9ed",
@@ -111,7 +103,6 @@
     "src/cli": "f2568e30f6af8d82c4e0d145dd8a9220",
     "src/hooks/json-error-recovery": "c8a245f5f48918279aa3d7724a573c75",
     "src/mcp": "f9241cd556adebddc643d71ee55ee2a8",
-    "src/tools/lsp": "2593534242852eef443eb5015fe01ba1",
     "src/hooks/foreground-fallback": "7d31d4b918d1e1e1b674dece98e10c4d",
     "src/tools": "e008189e552e226da488a42075d5ec07",
     "src/tools/ast-grep": "bc2c805d1593254804e74a5ea20c7cad",

+ 0 - 9
.slim/codemap.json

@@ -135,14 +135,6 @@
     "src/tools/background.ts": "1b979e158a1ca515fae2755729d449aa",
     "src/tools/council.ts": "4edec9c98d6e6d4082e5ee4eba0aa9c7",
     "src/tools/index.ts": "f31e1c25b9af8375dba09a4b16766566",
-    "src/tools/lsp/client.ts": "c8821a8c2cc21c51c26ffb50759e7411",
-    "src/tools/lsp/config-store.ts": "e10072482fbb91b9214fa3bf333aa033",
-    "src/tools/lsp/config.ts": "13aeb4cab14b73db792f6411cc775f8a",
-    "src/tools/lsp/constants.ts": "06f4618b3937e6aaa6ec4076c2e9ed51",
-    "src/tools/lsp/index.ts": "913a5e5cb7f366a05e618905e65b4900",
-    "src/tools/lsp/tools.ts": "023d712c7a06ab9e43851826114309a0",
-    "src/tools/lsp/types.ts": "e11904f9c738f10670abbfbaa185004d",
-    "src/tools/lsp/utils.ts": "c64858938255ab3c964195106af9bff9",
     "src/tools/smartfetch/binary.ts": "a65d816f46ebef11c39bda1764f82bb7",
     "src/tools/smartfetch/cache.ts": "9a4e272b897b6914f0925919357bfce1",
     "src/tools/smartfetch/constants.ts": "1ba20e00a4d3f4717eba62f381f9cd4c",
@@ -192,7 +184,6 @@
     "src/skills/simplify": "9c745d8113135e3103af5f1a49d67dfe",
     "src/tools": "2383d46a3f3878c98702e0470aca0ebb",
     "src/tools/ast-grep": "2d4ad34fd02c6d068766dd38e826f2a8",
-    "src/tools/lsp": "952fb176aeb025f24ad127b8ca43e3eb",
     "src/tools/smartfetch": "2b137fd3f65e105b005650b862b14fd7",
     "src/utils": "7280f2365b7cbed95d7553dfb79dd238"
   }

+ 1 - 2
AGENTS.md

@@ -73,7 +73,7 @@ oh-my-opencode-slim/
 │   ├── mcp/          # MCP server definitions
 │   ├── multiplexer/  # Tmux/Zellij pane integration for child sessions
 │   ├── skills/       # Skill definitions (included in package publish)
-│   ├── tools/        # Tool definitions (council, webfetch, LSP, etc.)
+│   ├── tools/        # Tool definitions (council, webfetch, AST-grep, etc.)
 │   └── utils/        # Shared utilities (tmux, session helpers)
 ├── dist/             # Built JavaScript and declarations
 ├── docs/             # User-facing documentation
@@ -87,7 +87,6 @@ oh-my-opencode-slim/
 - `@modelcontextprotocol/sdk` - MCP protocol implementation
 - `@opencode-ai/sdk` - OpenCode AI SDK
 - `zod` - Runtime validation
-- `vscode-jsonrpc` / `vscode-languageserver-protocol` - LSP support
 
 ## Development Workflow
 

+ 1 - 1
biome.json

@@ -1,5 +1,5 @@
 {
-  "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
+  "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
   "assist": { "actions": { "source": { "organizeImports": "on" } } },
   "files": {
     "includes": ["**", "!oh-my-opencode-slim.schema.json"]

+ 2 - 20
bun.lock

@@ -13,16 +13,12 @@
         "jsdom": "^26.1.0",
         "lru-cache": "^11.3.3",
         "turndown": "^7.2.4",
-        "vscode-jsonrpc": "^8.2.1",
-        "vscode-languageserver-protocol": "^3.17.5",
-        "which": "^6.0.1",
       },
       "devDependencies": {
         "@biomejs/biome": "2.4.11",
         "@types/jsdom": "^21.1.7",
         "@types/node": "^24.6.1",
         "@types/turndown": "^5.0.6",
-        "@types/which": "^3.0.4",
         "all-contributors-cli": "^6.26.1",
         "bun-types": "1.3.12",
         "typescript": "^5.9.3",
@@ -105,8 +101,6 @@
 
     "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
 
-    "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
-
     "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
 
     "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -267,7 +261,7 @@
 
     "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
 
-    "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
+    "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
 
     "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
 
@@ -435,12 +429,6 @@
 
     "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
 
-    "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
-
-    "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
-
-    "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
-
     "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
 
     "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
@@ -451,7 +439,7 @@
 
     "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
 
-    "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
+    "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
     "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
 
@@ -483,18 +471,12 @@
 
     "bun-types/@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
 
-    "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
-
     "external-editor/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
 
     "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
 
-    "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
-
     "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
 
-    "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
-
     "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
 
     "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],

+ 2 - 0
bunfig.toml

@@ -0,0 +1,2 @@
+[test]
+root = "./src"

+ 2 - 3
codemap.md

@@ -50,9 +50,8 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | `src/skills/` | Bundled install-time OpenCode skills shipped as static payloads. | [View Map](src/skills/codemap.md) |
 | `src/skills/codemap/` | Repository-mapping skill package and codemap state-management script. | [View Map](src/skills/codemap/codemap.md) |
 | `src/skills/simplify/` | Behavior-preserving simplification skill package. | [View Map](src/skills/simplify/codemap.md) |
-| `src/tools/` | Tool export surface and orchestration helpers for AST-grep, LSP, smartfetch, council, and background tools. | [View Map](src/tools/codemap.md) |
+| `src/tools/` | Tool export surface and orchestration helpers for AST-grep, smartfetch, council, and background tools. | [View Map](src/tools/codemap.md) |
 | `src/tools/ast-grep/` | AST-grep binary management and AST-aware search/replace tool flow. | [View Map](src/tools/ast-grep/codemap.md) |
-| `src/tools/lsp/` | LSP client pool, transport, formatting, and workspace-edit application. | [View Map](src/tools/lsp/codemap.md) |
 | `src/tools/smartfetch/` | Fetch/extract/cache pipeline for web content and secondary-model summarization. | [View Map](src/tools/smartfetch/codemap.md) |
 | `src/utils/` | Cross-cutting helpers for logging, sessions, compatibility, zip extraction, and runtime metadata. | [View Map](src/utils/codemap.md) |
 | `scripts/` | Build/release validation and generated-artifact maintenance scripts. | [View Map](scripts/codemap.md) |
@@ -85,7 +84,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 ## Key Cross-Module Integration Points
 
 - `src/index.ts` is the central composition root for nearly every runtime subsystem.
-- `src/config/` feeds `src/agents/`, `src/tools/lsp/`, session/delegation utilities, and MCP registration.
+- `src/config/` feeds `src/agents/`, session/delegation utilities, and MCP registration.
 - `src/cli/skills.ts` and `src/cli/custom-skills.ts` bridge install-time skill packaging with runtime permission policy.
 - Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/`.
 - `src/tools/council.ts` delegates into `src/council/`.

+ 0 - 15
docs/tools.md

@@ -20,21 +20,6 @@ Fetch remote pages with content extraction tuned for docs/static sites.
 
 ---
 
-## LSP Tools
-
-Language Server Protocol integration for code intelligence across 30+ languages. OpenCode ships pre-configured LSP servers for TypeScript, Python, Rust, Go, and more.
-
-| Tool | Description |
-|------|-------------|
-| `lsp_goto_definition` | Jump to a symbol's definition |
-| `lsp_find_references` | Find all usages of a symbol across the workspace |
-| `lsp_diagnostics` | Get errors and warnings from the language server |
-| `lsp_rename` | Rename a symbol across all files atomically |
-
-> See the [official OpenCode docs](https://opencode.ai/docs/lsp/#built-in) for the full list of built-in LSP servers and their requirements.
-
----
-
 ## Code Search Tools
 
 Fast, structural code search and refactoring — more powerful than plain text grep.

+ 1 - 5
package.json

@@ -66,17 +66,13 @@
     "@opencode-ai/sdk": "^1.3.17",
     "jsdom": "^26.1.0",
     "lru-cache": "^11.3.3",
-    "turndown": "^7.2.4",
-    "vscode-jsonrpc": "^8.2.1",
-    "vscode-languageserver-protocol": "^3.17.5",
-    "which": "^6.0.1"
+    "turndown": "^7.2.4"
   },
   "devDependencies": {
     "@biomejs/biome": "2.4.11",
     "@types/jsdom": "^21.1.7",
     "@types/node": "^24.6.1",
     "@types/turndown": "^5.0.6",
-    "@types/which": "^3.0.4",
     "all-contributors-cli": "^6.26.1",
     "bun-types": "1.3.12",
     "typescript": "^5.9.3",

+ 2 - 2
src/agents/codemap.md

@@ -64,7 +64,7 @@ All agents follow a consistent factory pattern:
 | Librarian | External docs | context7, grep_app, websearch | Evidence-based, citations required | 0.1 |
 | Oracle | Architecture guidance | Analysis tools, code review | Read-only, advisory | 0.1 |
 | Designer | UI/UX implementation | Tailwind, CSS, animations | Visual excellence priority | 0.7 |
-| Fixer | Implementation | Edit/write, lsp_diagnostics | No research/delegation, structured output | 0.2 |
+| Fixer | Implementation | Edit/write, grep, glob, read | No research/delegation, structured output | 0.2 |
 
 ## Flow
 
@@ -141,7 +141,7 @@ Parallelize (if applicable)
 Execute & Integrate
-Verify (lsp_diagnostics, tests)
+Verify (relevant checks, tests)
 ```
 
 ### Agent Interaction Patterns

+ 3 - 2
src/agents/councillor.test.ts

@@ -83,13 +83,14 @@ describe('councillor permissions', () => {
     expect(permission.read).toBe('allow');
     expect(permission.glob).toBe('allow');
     expect(permission.grep).toBe('allow');
+    expect(permission.lsp).toBe('allow');
   });
 
-  test('allows lsp and list tools', () => {
+  test('allows list and codesearch tools', () => {
     const agent = createCouncillorAgent('test-model');
     const permission = agent.config.permission as Record<string, string>;
-    expect(permission.lsp).toBe('allow');
     expect(permission.list).toBe('allow');
+    expect(permission.codesearch).toBe('allow');
   });
 
   test('has exactly 8 permission entries', () => {

+ 1 - 1
src/agents/councillor.ts

@@ -23,8 +23,8 @@ problem.
 - Read files (read)
 - Search by name patterns (glob)
 - Search by content (grep)
-- Query language server (lsp_diagnostics, lsp_goto_definition, lsp_find_references)
 - Search code patterns (ast_grep_search)
+- Use OpenCode's built-in \`lsp\` tool when available
 - Search external docs (if MCPs are configured for this agent)
 
 You CANNOT edit files, write files, run shell commands, or delegate to \

+ 4 - 4
src/agents/fixer.ts

@@ -10,14 +10,14 @@ const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 - Read files before using edit/write tools and gather exact content before making changes
 - Be fast and direct - no research, no delegation, No multi-step research/planning; minimal execution sequence ok
 - Write or update tests when requested, especially for bounded tasks involving test files, fixtures, mocks, or test helpers
-- Run tests/lsp_diagnostics when relevant or requested (otherwise note as skipped with reason)
+- Run relevant validation when requested or clearly applicable (otherwise note as skipped with reason)
 - Report completion with summary of changes
 
 **Constraints**:
 - NO external research (no websearch, context7, grep_app)
 - NO delegation or spawning subagents
 - No multi-step research/planning; minimal execution sequence ok
-- If context is insufficient: use grep/glob/lsp_diagnostics directly — do not delegate
+- If context is insufficient: use grep/glob/read directly — do not delegate
 - Only ask for missing inputs you truly cannot retrieve yourself
 - Do not act as the primary reviewer; implement requested changes and surface obvious issues briefly
 
@@ -31,7 +31,7 @@ Brief summary of what was implemented
 </changes>
 <verification>
 - Tests passed: [yes/no/skip reason]
-- LSP diagnostics: [clean/errors found/skip reason]
+- Validation: [passed/failed/skip reason]
 </verification>
 
 Use the following when no code changes were made:
@@ -40,7 +40,7 @@ No changes required
 </summary>
 <verification>
 - Tests passed: [not run - reason]
-- LSP diagnostics: [not run - reason]
+- Validation: [not run - reason]
 </verification>`;
 
 export function createFixerAgent(

+ 1 - 1
src/agents/orchestrator.ts

@@ -191,7 +191,7 @@ When working through multi-step tasks, consider enabling auto-continue to avoid
 ${enabledValidationRouting}
 
 ## 6. Verify
-- Run \`lsp_diagnostics\` for errors
+- Run relevant checks/diagnostics for the change
 - Use validation routing when applicable instead of doing all review work yourself
 - If test files are involved, prefer @fixer for bounded test changes and @oracle only for test strategy or quality review
 - Confirm specialists completed successfully

+ 2 - 2
src/codemap.md

@@ -9,11 +9,11 @@
 - Agent creation follows explicit factories (`agents/index.ts`, per-agent creators under `agents/`) with override/permission helpers (`config/utils.ts`, `cli/skills.ts`) so defaults live in `config/constants.ts`, prompts can be swapped via `config/loader.ts`, and variant labels propagate through `utils/agent-variant.ts`.
 - Session orchestration combines `MultiplexerSessionManager` with `SubagentDepthTracker` and `multiplexer/*` so child agent sessions are depth-tracked, can surface in panes, and are cleaned up consistently.
 - Hooks are isolated (`hooks/auto-update-checker`, `phase-reminder`, `post-file-tool-nudge`) and exported via `hooks/index.ts`, so the plugin simply registers them via the `event`, `experimental.chat.system.transform`, `experimental.chat.messages.transform`, and `tool.execute.after` hooks defined in `index.ts`.
-- Supplemental tools (`tools/grep`, `tools/lsp`, `tools/quota`) bundle ripgrep, LSP helpers, and Antigravity quota calls behind the OpenCode `tool` interface and are mounted in `index.ts` alongside council/webfetch helpers.
+- Supplemental tools bundle AST-grep search/replace, council orchestration, and web fetching behind the OpenCode `tool` interface and are mounted in `index.ts` alongside hooks and MCP helpers.
 
 ## Flow
 - Startup: `index.ts` calls `loadPluginConfig` (user + project JSON + presets) to build a `PluginConfig`, passes it to `getAgentConfigs` (which uses `createAgents`, agent factories, `loadAgentPrompt`, and `getAgentMcpList`) and to `MultiplexerSessionManager`/`CouncilManager` so the in-memory state matches user overrides.
-- Plugin registration: `index.ts` registers agents, the tool map (council, `webfetch`, `ast_grep_*`, `lsp_*`), MCP definitions (`createBuiltinMcps`), and hooks (`createAutoUpdateCheckerHook`, `createPhaseReminderHook`, `createPostReadNudgeHook`); configuration hook merges those values back into the OpenCode config (default agent, permission rules parsed from `config/agent-mcps`, and MCP access policies).
+- Plugin registration: `index.ts` registers agents, the tool map (council, `webfetch`, `ast_grep_*`), MCP definitions (`createBuiltinMcps`), and hooks (`createAutoUpdateCheckerHook`, `createPhaseReminderHook`, `createPostReadNudgeHook`); configuration hook merges those values back into the OpenCode config (default agent, permission rules parsed from `config/agent-mcps`, and MCP access policies).
 - Runtime: `MultiplexerSessionManager` observes `session.created` events to spawn panes via multiplexer backends and closes them when sessions idle or are deleted, while session events also feed `SubagentDepthTracker` so nested child sessions remain bounded.
 - CLI flow: `cli/install.ts` parses flags, optionally asks interactive prompts, checks OpenCode installation, adds plugin entries via `cli/config-manager.ts`, disables default agents, writes the lite config (`cli/config-io.ts`), and installs skills (`cli/skills.ts`, `cli/custom-skills.ts`).
 

+ 0 - 16
src/index.ts

@@ -29,11 +29,6 @@ import {
   ast_grep_search,
   createCouncilTool,
   createWebfetchTool,
-  lsp_diagnostics,
-  lsp_find_references,
-  lsp_goto_definition,
-  lsp_rename,
-  setUserLspConfig,
 } from './tools';
 import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
 import { initLogger, log } from './utils/logger';
@@ -259,7 +254,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       Object.keys(councilTools).length +
       Object.keys(todoContinuationHook.tool).length +
       1 + // webfetch
-      4 + // lsp_goto_definition, lsp_find_references, lsp_diagnostics, lsp_rename
       2; // ast_grep_search, ast_grep_replace
   } catch (err) {
     // Plugin init failed: log visibly before re-throwing so the user
@@ -326,10 +320,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ...councilTools,
       webfetch,
       ...todoContinuationHook.tool,
-      lsp_goto_definition,
-      lsp_find_references,
-      lsp_diagnostics,
-      lsp_rename,
       ast_grep_search,
       ast_grep_replace,
     },
@@ -337,12 +327,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     mcp: mcps,
 
     config: async (opencodeConfig: Record<string, unknown>) => {
-      // Set user's lsp config from opencode.json for LSP tools
-      const lspConfig = opencodeConfig.lsp as
-        | Record<string, unknown>
-        | undefined;
-      setUserLspConfig(lspConfig);
-
       // Only set default_agent if not already configured by the user
       // and the plugin config doesn't explicitly disable this behavior
       if (

+ 43 - 44
src/interview/manager.test.ts

@@ -53,6 +53,17 @@ function createMockContext(overrides?: {
   } as any;
 }
 
+function createTestConfig(
+  overrides: Partial<NonNullable<PluginConfig['interview']>> = {},
+): PluginConfig {
+  return {
+    interview: {
+      autoOpenBrowser: false,
+      ...overrides,
+    },
+  } as PluginConfig;
+}
+
 // Helper to extract text from output parts
 function _extractOutputText(output: {
   parts: Array<{ type: string; text?: string }>;
@@ -65,7 +76,7 @@ describe('interview manager - per-session mode', () => {
   describe('basic functionality', () => {
     test('returns correct interface when port is 0 (default)', () => {
       const ctx = createMockContext();
-      const config = { interview: { port: 0 } } as PluginConfig;
+      const config = createTestConfig({ port: 0 });
 
       const manager = createInterviewManager(ctx, config);
 
@@ -80,7 +91,7 @@ describe('interview manager - per-session mode', () => {
     test('creates interview with /interview command', async () => {
       const tempDir = await fs.mkdtemp('/tmp/manager-test-');
       const ctx = createMockContext({ directory: tempDir });
-      const config = { interview: { port: 0 } } as PluginConfig;
+      const config = createTestConfig({ port: 0 });
 
       const manager = createInterviewManager(ctx, config);
       const output = { parts: [] as Array<{ type: string; text?: string }> };
@@ -107,7 +118,7 @@ describe('interview manager - per-session mode', () => {
     test('marks interview as abandoned on session.deleted event', async () => {
       const tempDir = await fs.mkdtemp('/tmp/manager-test-');
       const ctx = createMockContext({ directory: tempDir });
-      const config = { interview: { port: 0 } } as PluginConfig;
+      const config = createTestConfig({ port: 0 });
 
       const manager = createInterviewManager(ctx, config);
 
@@ -146,12 +157,10 @@ describe('interview manager - per-session mode', () => {
       const ctx = createMockContext({ directory: tempDir });
 
       const freePort = await findFreePort();
-      const config = {
-        interview: {
-          port: freePort,
-          dashboard: true,
-        },
-      } as PluginConfig;
+      const config = createTestConfig({
+        port: freePort,
+        dashboard: true,
+      });
 
       const manager = createInterviewManager(ctx, config);
 
@@ -203,12 +212,10 @@ describe('interview manager - per-session mode', () => {
       const tempDir = await fs.mkdtemp('/tmp/manager-test-');
       const ctx = createMockContext({ directory: tempDir });
 
-      const config = {
-        interview: {
-          port: freePort,
-          dashboard: true,
-        },
-      } as PluginConfig;
+      const config = createTestConfig({
+        port: freePort,
+        dashboard: true,
+      });
 
       const manager = createInterviewManager(ctx, config);
 
@@ -241,12 +248,10 @@ describe('interview manager - state push callback wiring', () => {
     const ctx = createMockContext({ directory: tempDir });
 
     const freePort = await findFreePort();
-    const config = {
-      interview: {
-        port: freePort,
-        dashboard: true,
-      },
-    } as PluginConfig;
+    const config = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -300,7 +305,7 @@ describe('interview manager - state push callback wiring', () => {
   test('in per-session mode, setBaseUrlResolver is called', async () => {
     const tempDir = await fs.mkdtemp('/tmp/manager-test-');
     const ctx = createMockContext({ directory: tempDir });
-    const config = { interview: { port: 0 } } as PluginConfig;
+    const config = createTestConfig({ port: 0 });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -332,12 +337,10 @@ describe('interview manager - session registration', () => {
     const ctx = createMockContext({ directory: tempDir });
 
     const freePort = await findFreePort();
-    const config = {
-      interview: {
-        port: freePort,
-        dashboard: true,
-      },
-    } as PluginConfig;
+    const config = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -387,12 +390,10 @@ describe('interview manager - session registration', () => {
     const ctx = createMockContext({ directory: tempDir });
 
     const freePort = await findFreePort();
-    const config = {
-      interview: {
-        port: freePort,
-        dashboard: true,
-      },
-    } as PluginConfig;
+    const config = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -449,7 +450,7 @@ describe('interview manager - edge cases', () => {
   test('handles session.status event with idle status', async () => {
     const tempDir = await fs.mkdtemp('/tmp/manager-test-');
     const ctx = createMockContext({ directory: tempDir });
-    const config = { interview: { port: 0 } } as PluginConfig;
+    const config = createTestConfig({ port: 0 });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -486,7 +487,7 @@ describe('interview manager - edge cases', () => {
   test('handles session.status event without sessionID in properties', async () => {
     const tempDir = await fs.mkdtemp('/tmp/manager-test-');
     const ctx = createMockContext({ directory: tempDir });
-    const config = { interview: { port: 0 } } as PluginConfig;
+    const config = createTestConfig({ port: 0 });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -511,7 +512,7 @@ describe('interview manager - edge cases', () => {
   test('handles unknown event types', async () => {
     const tempDir = await fs.mkdtemp('/tmp/manager-test-');
     const ctx = createMockContext({ directory: tempDir });
-    const config = { interview: { port: 0 } } as PluginConfig;
+    const config = createTestConfig({ port: 0 });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -534,7 +535,7 @@ describe('interview manager - edge cases', () => {
   test('handles handleCommandExecuteBefore without sessionID', async () => {
     const tempDir = await fs.mkdtemp('/tmp/manager-test-');
     const ctx = createMockContext({ directory: tempDir });
-    const config = { interview: { port: 0 } } as PluginConfig;
+    const config = createTestConfig({ port: 0 });
 
     const manager = createInterviewManager(ctx, config);
 
@@ -566,12 +567,10 @@ describe('interview manager - integration with real dashboard', () => {
     const ctx2 = createMockContext({ directory: tempDir2 });
 
     const freePort = await findFreePort();
-    const config = {
-      interview: {
-        port: freePort,
-        dashboard: true,
-      },
-    } as PluginConfig;
+    const config = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
 
     const manager1 = createInterviewManager(ctx1, config);
 

+ 1 - 1
src/interview/service.ts

@@ -40,7 +40,7 @@ import type {
 
 const COMMAND_NAME = 'interview';
 const DEFAULT_MAX_QUESTIONS = 2;
-const DEFAULT_AUTO_OPEN_BROWSER = true;
+const DEFAULT_AUTO_OPEN_BROWSER = process.env.NODE_ENV !== 'test';
 
 /**
  * Open a URL in the default browser.

+ 2 - 32
src/tools/codemap.md

@@ -2,21 +2,16 @@
 
 - Expose plugin tool definitions for code intelligence and workflow tooling from
   `src/tools/index.ts`.
-- Publish and compose three primary operational domains:
+- Publish and compose two primary operational domains:
   - AST pattern search/replace via `ast-grep/`.
-  - Language server tooling via `lsp/`.
   - URL fetch/transform with optional secondary model via `smartfetch/`.
 - Provide runtime factories for orchestration helpers:
   - `createCouncilTool` (`council.ts`).
-- Expose runtime entry contracts (`lspManager`, `setUserLspConfig`, utility
-  constants/types) for plugin bootstrap and config hooks.
 
 ## Design
 
 - `src/tools/index.ts` is the canonical export surface. It re-exports:
   - `ast_grep_search`, `ast_grep_replace`.
-  - `lsp_diagnostics`, `lsp_find_references`, `lsp_goto_definition`,
-    `lsp_rename`, `lspManager`, `setUserLspConfig`.
   - `createWebfetchTool`.
   - `createCouncilTool`.
 - **Common tool schema pattern**: all tool files use `@opencode-ai/plugin/tool` or
@@ -30,18 +25,6 @@
   - `downloader.ts` implements `ensureAstGrepBinary` and release-specific
     fallback download.
   - `utils.ts` centralizes user-facing renderers.
-- **LSP stack (`lsp/`)**:
-  - `client.ts` implements `LSPServerManager` singleton (`lspManager`) and
-    `LSPClient` with `start`, `initialize`, `definition`, `references`,
-    `diagnostics`, `rename`, `stop`.
-  - `config.ts` resolves server selection via `findServerForExtension` and
-    `resolveServerCommand`.
-  - `config-store.ts` persists `setUserLspConfig`/`getUserLspConfig` from
-    OpenCode `lsp` config.
-  - `types.ts` exports protocol-aligned types (`Diagnostic`, `Location`,
-    `WorkspaceEdit`, `ServerLookupResult`, etc.).
-  - `utils.ts` handles formatting, root discovery, `withLspClient`, and
-    `applyWorkspaceEdit`.
 - **Smartfetch stack (`smartfetch/`)**:
   - `tool.ts` defines `createWebfetchTool` and the complete execution path.
   - `network.ts` enforces redirect policy, read limits, HTML/binary detection,
@@ -61,16 +44,6 @@
     fallback download if missing, and executes with timeout.
   - JSON output is parsed into `SgResult`, respecting truncation/error states.
   - `formatSearchResult` / `formatReplaceResult` produce output.
-- **LSP path**:
-  - Tool executes one of four LSP handlers in `lsp/tools.ts`.
-  - `withLspClient` resolves extension using `findServerForExtension` and
-    computes workspace root.
-  - `lspManager.getClient` creates or reuses a pooled `LSPClient`, waits for
-    initialize, and increments/decrements `refCount`.
-  - Client opens file (`openFile`/`ensureDocumentSynced`) then issues protocol
-    requests.
-  - Formatters convert protocol data to plain output strings; edits are applied
-    with `applyWorkspaceEdit`.
 - **Smartfetch path**:
   - `createWebfetchTool` validates permissions (`ctx.ask`) and timeout, reads
     secondary model candidates, then checks cache (`CACHE`).
@@ -87,12 +60,9 @@
 ## Integration
 
 - `src/index.ts` imports these exports and injects them into plugin tool surfaces.
-- `setUserLspConfig` is called during plugin initialization so `findServerForExtension`
-  reflects active `lsp` config.
 - OpenCode-facing dependencies used directly in these modules:
   - `@opencode-ai/plugin` / `@opencode-ai/plugin/tool` (`tool`, schemas).
-  - `vscode-jsonrpc` + `vscode-languageserver-protocol`.
-  - `which`, `lru-cache`, `bun` runtime APIs, network stack.
+  - `lru-cache`, `bun` runtime APIs, network stack.
   - DOM extraction libs in smartfetch.
 - Consumers include orchestrator/council agents, `@opencode` task runners, and
   any extension tests that import tools/types from the `src/tools` modules.

+ 0 - 8
src/tools/index.ts

@@ -1,12 +1,4 @@
 // AST-grep tools
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCouncilTool } from './council';
-export {
-  lsp_diagnostics,
-  lsp_find_references,
-  lsp_goto_definition,
-  lsp_rename,
-  lspManager,
-  setUserLspConfig,
-} from './lsp';
 export { createWebfetchTool } from './smartfetch';

+ 0 - 550
src/tools/lsp/client.test.ts

@@ -1,550 +0,0 @@
-import {
-  afterEach,
-  beforeEach,
-  describe,
-  expect,
-  mock,
-  spyOn,
-  test,
-} from 'bun:test';
-import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
-import { join, resolve } from 'node:path';
-import { pathToFileURL } from 'node:url';
-
-// Mock spawn from bun
-mock.module('bun', () => ({
-  spawn: mock().mockReturnValue({
-    stdin: {
-      write: mock(),
-      end: mock(),
-    },
-    stdout: {
-      getReader: () => ({
-        read: () => Promise.resolve({ done: true, value: undefined }),
-      }),
-    },
-    stderr: {
-      getReader: () => ({
-        read: () => Promise.resolve({ done: true, value: undefined }),
-      }),
-    },
-    kill: mock(),
-    exitCode: null,
-  }),
-}));
-
-import {
-  getDiagnosticsCapabilitySummary,
-  getWorkspaceConfiguration,
-  LSP_TIMEOUTS,
-  LSPClient,
-  lspManager,
-} from './client';
-
-describe('LSPServerManager', () => {
-  let startSpy: any;
-  let initSpy: any;
-  let aliveSpy: any;
-  let stopSpy: any;
-
-  beforeEach(async () => {
-    await lspManager.stopAll();
-    startSpy = spyOn(LSPClient.prototype, 'start').mockResolvedValue(undefined);
-    initSpy = spyOn(LSPClient.prototype, 'initialize').mockResolvedValue(
-      undefined,
-    );
-    aliveSpy = spyOn(LSPClient.prototype, 'isAlive').mockReturnValue(true);
-    stopSpy = spyOn(LSPClient.prototype, 'stop').mockResolvedValue(undefined);
-  });
-
-  afterEach(async () => {
-    startSpy.mockRestore();
-    initSpy.mockRestore();
-    aliveSpy.mockRestore();
-    stopSpy.mockRestore();
-    await lspManager.stopAll();
-  });
-
-  test('getClient should create new client and reuse it', async () => {
-    const server = {
-      id: 'test',
-      command: ['test-server'],
-      extensions: ['.test'],
-    };
-    const root = '/root';
-
-    const client1 = await lspManager.getClient(root, server);
-    expect(startSpy).toHaveBeenCalledTimes(1);
-
-    const client2 = await lspManager.getClient(root, server);
-    expect(startSpy).toHaveBeenCalledTimes(1); // Should be reused
-    expect(client1).toBe(client2);
-  });
-
-  test('releaseClient should decrement ref count', async () => {
-    const server = {
-      id: 'test',
-      command: ['test-server'],
-      extensions: ['.test'],
-    };
-    const root = '/root';
-
-    await lspManager.getClient(root, server);
-    const managed = (lspManager as any).clients.get(`${root}::${server.id}`);
-    expect(managed.refCount).toBe(1);
-
-    lspManager.releaseClient(root, server.id);
-    expect(managed.refCount).toBe(0);
-  });
-
-  test('cleanupIdleClients should remove idle clients', async () => {
-    const server = {
-      id: 'test',
-      command: ['test-server'],
-      extensions: ['.test'],
-    };
-    const root = '/root';
-
-    await lspManager.getClient(root, server);
-    lspManager.releaseClient(root, server.id);
-
-    const managed = (lspManager as any).clients.get(`${root}::${server.id}`);
-    managed.lastUsedAt = Date.now() - 6 * 60 * 1000;
-
-    (lspManager as any).cleanupIdleClients();
-
-    expect((lspManager as any).clients.has(`${root}::${server.id}`)).toBe(
-      false,
-    );
-    expect(stopSpy).toHaveBeenCalled();
-  });
-
-  test('stopAll should stop all clients', async () => {
-    await lspManager.getClient('/root1', {
-      id: 's1',
-      command: ['c1'],
-      extensions: ['.1'],
-    });
-    await lspManager.getClient('/root2', {
-      id: 's2',
-      command: ['c2'],
-      extensions: ['.2'],
-    });
-
-    // Reset stopSpy because getClient might have called stop if there were old clients
-    stopSpy.mockClear();
-
-    await lspManager.stopAll();
-
-    expect((lspManager as any).clients.size).toBe(0);
-    expect(stopSpy).toHaveBeenCalledTimes(2);
-  });
-
-  test('should register process cleanup handlers', () => {
-    const onSpy = spyOn(process, 'on');
-    // We need to create a new instance or trigger the registration
-    // Since it's a singleton, we can just check if it was called during init
-    // But it already happened. Let's check if the handlers are there.
-    // Actually, we can just verify that it's intended to be called.
-
-    // For the sake of this test, let's just see if process.on was called with expected events
-    // This might be tricky if it happened before we started spying.
-
-    // Instead, let's just verify that stopAll is exported and works, which we already did.
-    expect(onSpy).toBeDefined();
-    onSpy.mockRestore();
-  });
-});
-
-describe('LSPClient diagnostics', () => {
-  const tempDir = join(process.cwd(), '.tmp-lsp-tests');
-  const tempFile = join(tempDir, 'index.ts');
-
-  beforeEach(async () => {
-    await lspManager.stopAll();
-    mkdirSync(tempDir, { recursive: true });
-    writeFileSync(tempFile, 'export const value = 1;\n');
-  });
-
-  afterEach(async () => {
-    rmSync(tempDir, { recursive: true, force: true });
-    await lspManager.stopAll();
-  });
-
-  test('diagnostics falls back to cached publishDiagnostics on timeout', async () => {
-    const originalDiagnosticsTimeout = LSP_TIMEOUTS.diagnostics;
-    LSP_TIMEOUTS.diagnostics = 10;
-
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    (client as any).supportsPullDiagnostics = true;
-
-    (client as any).connection = {
-      sendNotification: mock(),
-      sendRequest: mock((method: string) => {
-        if (method === 'textDocument/diagnostic') {
-          return new Promise(() => {});
-        }
-        return Promise.resolve({});
-      }),
-    };
-
-    (client as any).diagnosticsStore.set(pathToFileURL(tempFile).href, [
-      {
-        message: 'cached diagnostic',
-        range: {
-          start: { line: 0, character: 0 },
-          end: { line: 0, character: 1 },
-        },
-        severity: 1,
-      },
-    ]);
-
-    try {
-      const result = await client.diagnostics(tempFile);
-      expect(result.items).toHaveLength(1);
-      expect(result.items[0]?.message).toBe('cached diagnostic');
-    } finally {
-      LSP_TIMEOUTS.diagnostics = originalDiagnosticsTimeout;
-    }
-  });
-
-  test('diagnostics throws after timeout when no cached diagnostics exist', async () => {
-    const originalDiagnosticsTimeout = LSP_TIMEOUTS.diagnostics;
-    LSP_TIMEOUTS.diagnostics = 10;
-
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    (client as any).supportsPullDiagnostics = true;
-
-    (client as any).connection = {
-      sendNotification: mock(),
-      sendRequest: mock((method: string) => {
-        if (method === 'textDocument/diagnostic') {
-          return new Promise(() => {});
-        }
-        return Promise.resolve({});
-      }),
-    };
-
-    try {
-      await expect(client.diagnostics(tempFile)).rejects.toThrow(
-        'Unable to retrieve diagnostics',
-      );
-    } finally {
-      LSP_TIMEOUTS.diagnostics = originalDiagnosticsTimeout;
-    }
-  });
-
-  test('diagnostics uses cached publishDiagnostics for push-only servers', async () => {
-    const originalDiagnosticsTimeout = LSP_TIMEOUTS.diagnostics;
-    LSP_TIMEOUTS.diagnostics = 200;
-
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    const sendRequest = mock(() => Promise.resolve({}));
-
-    (client as any).connection = {
-      sendNotification: mock(
-        (method: string, params?: { textDocument?: { uri?: string } }) => {
-          if (method === 'textDocument/didOpen') {
-            setTimeout(() => {
-              (client as any).diagnosticsStore.set(params?.textDocument?.uri, [
-                {
-                  message: 'push diagnostic',
-                  range: {
-                    start: { line: 0, character: 0 },
-                    end: { line: 0, character: 1 },
-                  },
-                  severity: 2,
-                },
-              ]);
-            }, 10);
-          }
-        },
-      ),
-      sendRequest,
-    };
-
-    try {
-      const result = await client.diagnostics(tempFile);
-      expect(result.items).toHaveLength(1);
-      expect(result.items[0]?.message).toBe('push diagnostic');
-      expect(sendRequest).not.toHaveBeenCalledWith(
-        'textDocument/diagnostic',
-        expect.anything(),
-      );
-    } finally {
-      LSP_TIMEOUTS.diagnostics = originalDiagnosticsTimeout;
-    }
-  });
-
-  test('diagnostics handles pull full reports and stores resultId', async () => {
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    (client as any).supportsPullDiagnostics = true;
-    (client as any).connection = {
-      sendNotification: mock(),
-      sendRequest: mock((method: string) => {
-        if (method === 'textDocument/diagnostic') {
-          return Promise.resolve({
-            kind: 'full',
-            items: [
-              {
-                message: 'pull diagnostic',
-                range: {
-                  start: { line: 0, character: 0 },
-                  end: { line: 0, character: 1 },
-                },
-                severity: 1,
-              },
-            ],
-            resultId: 'r1',
-          });
-        }
-        return Promise.resolve({});
-      }),
-    };
-
-    const result = await client.diagnostics(tempFile);
-    expect(result.items).toHaveLength(1);
-    expect(result.items[0]?.message).toBe('pull diagnostic');
-    expect(
-      (client as any).diagnosticResultIds.get(pathToFileURL(tempFile).href),
-    ).toBe('r1');
-  });
-
-  test('diagnostics handles pull unchanged reports using cached diagnostics', async () => {
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    const uri = pathToFileURL(tempFile).href;
-    (client as any).supportsPullDiagnostics = true;
-    (client as any).diagnosticsStore.set(uri, [
-      {
-        message: 'cached from previous full',
-        range: {
-          start: { line: 0, character: 0 },
-          end: { line: 0, character: 1 },
-        },
-        severity: 2,
-      },
-    ]);
-    (client as any).diagnosticResultIds.set(uri, 'r1');
-    (client as any).connection = {
-      sendNotification: mock(),
-      sendRequest: mock((method: string) => {
-        if (method === 'textDocument/diagnostic') {
-          return Promise.resolve({ kind: 'unchanged', resultId: 'r2' });
-        }
-        return Promise.resolve({});
-      }),
-    };
-
-    const result = await client.diagnostics(tempFile);
-    expect(result.items).toHaveLength(1);
-    expect(result.items[0]?.message).toBe('cached from previous full');
-    expect((client as any).diagnosticResultIds.get(uri)).toBe('r2');
-  });
-
-  test('external file change invalidates cached diagnostics and waits for fresh publish', async () => {
-    const tempDir = join(process.cwd(), '.tmp-lsp-tests');
-    const tempFile = join(tempDir, 'index.ts');
-    mkdirSync(tempDir, { recursive: true });
-    writeFileSync(tempFile, 'const a = 1;\n');
-
-    const client = new LSPClient(tempDir, {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    const sendNotification = mock((method: string, params?: any) => {
-      if (method === 'textDocument/didOpen') {
-        // initial publish
-        (client as any).diagnosticsStore.set(params.textDocument.uri, [
-          {
-            message: 'initial',
-            range: {
-              start: { line: 0, character: 0 },
-              end: { line: 0, character: 1 },
-            },
-            severity: 2,
-          },
-        ]);
-      }
-    });
-
-    (client as any).connection = {
-      sendNotification,
-      sendRequest: mock(() => Promise.resolve({})),
-    };
-
-    try {
-      // first diagnostics returns initial cached one
-      (client as any).supportsPullDiagnostics = false;
-      const first = await client.diagnostics(tempFile);
-      expect(first.items[0].message).toBe('initial');
-
-      // mutate the file externally
-      writeFileSync(tempFile, 'const b = 2;\n');
-
-      // simulate server publishing new diagnostics after change
-      setTimeout(() => {
-        const uri = pathToFileURL(resolve(tempFile)).href;
-        (client as any).diagnosticsStore.set(uri, [
-          {
-            message: 'after',
-            range: {
-              start: { line: 0, character: 0 },
-              end: { line: 0, character: 1 },
-            },
-            severity: 2,
-          },
-        ]);
-      }, 20);
-
-      const second = await client.diagnostics(tempFile);
-      expect(second.items[0].message).toBe('after');
-    } finally {
-      rmSync(tempDir, { recursive: true, force: true });
-    }
-  });
-});
-
-describe('LSPClient initialize', () => {
-  test('advertises textDocument diagnostic capability', async () => {
-    const client = new LSPClient('/root', {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    const sendRequest = mock(() => Promise.resolve({}));
-    const sendNotification = mock();
-
-    (client as any).connection = {
-      sendRequest,
-      sendNotification,
-    };
-
-    await client.initialize();
-
-    expect((client as any).supportsPullDiagnostics).toBe(false);
-
-    expect(sendRequest).toHaveBeenCalledWith(
-      'initialize',
-      expect.objectContaining({
-        capabilities: expect.objectContaining({
-          textDocument: expect.objectContaining({
-            diagnostic: {},
-          }),
-        }),
-      }),
-    );
-    expect(sendNotification).toHaveBeenCalledWith('initialized', {});
-  });
-
-  test('stores negotiated pull diagnostics support from initialize response', async () => {
-    const client = new LSPClient('/root', {
-      id: 'test',
-      command: ['/bin/true'],
-      extensions: ['.ts'],
-    });
-
-    (client as any).connection = {
-      sendRequest: mock(() =>
-        Promise.resolve({
-          capabilities: {
-            diagnosticProvider: {
-              interFileDependencies: false,
-              workspaceDiagnostics: false,
-            },
-          },
-        }),
-      ),
-      sendNotification: mock(),
-    };
-
-    await client.initialize();
-
-    expect((client as any).supportsPullDiagnostics).toBe(true);
-    expect((client as any).diagnosticProvider).toEqual({
-      interFileDependencies: false,
-      workspaceDiagnostics: false,
-    });
-  });
-
-  test('returns null for unknown workspace configuration sections', () => {
-    expect(
-      getWorkspaceConfiguration([
-        { section: 'unknown' },
-        { section: 'json' },
-        {},
-      ]),
-    ).toEqual([null, { validate: { enable: true } }, null]);
-  });
-
-  test('summarizes pull-only diagnostics capabilities', () => {
-    expect(
-      getDiagnosticsCapabilitySummary({
-        diagnosticProvider: {
-          interFileDependencies: true,
-          workspaceDiagnostics: true,
-        },
-      }),
-    ).toEqual({
-      availableModes: ['pull', 'pull/full', 'pull/unchanged', 'workspace-pull'],
-      preferredMode: 'pull',
-      inferredTransport: 'pull',
-      pull: true,
-      pushObserved: false,
-      pullResultTracking: true,
-      workspaceDiagnostics: true,
-      interFileDependencies: true,
-      workspaceConfiguration: false,
-    });
-  });
-
-  test('summarizes hybrid diagnostics capabilities when push is observed', () => {
-    expect(
-      getDiagnosticsCapabilitySummary({
-        diagnosticProvider: {
-          interFileDependencies: false,
-          workspaceDiagnostics: false,
-        },
-        publishDiagnosticsObserved: true,
-        workspaceConfigurationRequested: true,
-      }),
-    ).toEqual({
-      availableModes: ['pull', 'pull/full', 'pull/unchanged', 'push'],
-      preferredMode: 'pull',
-      inferredTransport: 'hybrid',
-      pull: true,
-      pushObserved: true,
-      pullResultTracking: true,
-      workspaceDiagnostics: false,
-      interFileDependencies: false,
-      workspaceConfiguration: true,
-    });
-  });
-});

+ 0 - 787
src/tools/lsp/client.ts

@@ -1,787 +0,0 @@
-// LSP Client - Full implementation with connection pooling
-
-import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
-import { readFileSync } from 'node:fs';
-import { extname, resolve } from 'node:path';
-import { pathToFileURL } from 'node:url';
-import {
-  createMessageConnection,
-  type MessageConnection,
-  StreamMessageReader,
-  StreamMessageWriter,
-} from 'vscode-jsonrpc/node';
-import { log } from '../../utils/logger';
-import { getLanguageId, resolveServerCommand } from './config';
-import type {
-  Diagnostic,
-  DocumentDiagnosticReport,
-  ResolvedServer,
-} from './types';
-
-const START_TIMEOUT_MS = 5_000;
-const REQUEST_TIMEOUT_MS = 5_000;
-const DIAGNOSTICS_TIMEOUT_MS = 15_000;
-const OPEN_FILE_DELAY_MS = 250;
-const INITIALIZE_DELAY_MS = 100;
-const DIAGNOSTIC_SETTLE_DELAY_MS = 250;
-
-export const LSP_TIMEOUTS = {
-  start: START_TIMEOUT_MS,
-  request: REQUEST_TIMEOUT_MS,
-  diagnostics: DIAGNOSTICS_TIMEOUT_MS,
-  openFileDelay: OPEN_FILE_DELAY_MS,
-  initializeDelay: INITIALIZE_DELAY_MS,
-  diagnosticSettleDelay: DIAGNOSTIC_SETTLE_DELAY_MS,
-};
-
-interface DiagnosticProviderCapabilities {
-  identifier?: string;
-  interFileDependencies?: boolean;
-  workspaceDiagnostics?: boolean;
-}
-
-export function getDiagnosticsCapabilitySummary({
-  diagnosticProvider,
-  publishDiagnosticsObserved = false,
-  workspaceConfigurationRequested = false,
-}: {
-  diagnosticProvider?: DiagnosticProviderCapabilities | null;
-  publishDiagnosticsObserved?: boolean;
-  workspaceConfigurationRequested?: boolean;
-}): {
-  availableModes: string[];
-  preferredMode: 'push' | 'pull';
-  inferredTransport: 'push' | 'pull' | 'hybrid';
-  pull: boolean;
-  pushObserved: boolean;
-  pullResultTracking: boolean;
-  workspaceDiagnostics: boolean;
-  interFileDependencies: boolean;
-  workspaceConfiguration: boolean;
-} {
-  const pull = Boolean(diagnosticProvider);
-  const workspaceDiagnostics = Boolean(
-    diagnosticProvider?.workspaceDiagnostics,
-  );
-  const interFileDependencies = Boolean(
-    diagnosticProvider?.interFileDependencies,
-  );
-
-  const availableModes = [
-    ...(pull ? ['pull', 'pull/full', 'pull/unchanged'] : ['push']),
-    ...(workspaceDiagnostics ? ['workspace-pull'] : []),
-    ...(publishDiagnosticsObserved ? ['push'] : []),
-  ];
-
-  return {
-    availableModes: Array.from(new Set(availableModes)),
-    preferredMode: pull ? 'pull' : 'push',
-    inferredTransport:
-      pull && publishDiagnosticsObserved ? 'hybrid' : pull ? 'pull' : 'push',
-    pull,
-    pushObserved: publishDiagnosticsObserved,
-    pullResultTracking: pull,
-    workspaceDiagnostics,
-    interFileDependencies,
-    workspaceConfiguration: workspaceConfigurationRequested,
-  };
-}
-
-function withTimeout<T>(
-  promise: Promise<T>,
-  ms: number,
-  label: string,
-  onTimeout?: () => Promise<void> | void,
-): Promise<T> {
-  return new Promise((resolve, reject) => {
-    let settled = false;
-    const timer = setTimeout(() => {
-      if (settled) {
-        return;
-      }
-      settled = true;
-      void Promise.resolve(onTimeout?.()).catch(() => {});
-      reject(new Error(`${label} timeout after ${ms}ms`));
-    }, ms);
-
-    promise.then(
-      (value) => {
-        if (settled) {
-          return;
-        }
-        settled = true;
-        clearTimeout(timer);
-        resolve(value);
-      },
-      (error) => {
-        if (settled) {
-          return;
-        }
-        settled = true;
-        clearTimeout(timer);
-        reject(error);
-      },
-    );
-  });
-}
-
-export function getWorkspaceConfiguration(
-  items: Array<{ section?: string } | undefined>,
-): Array<unknown> {
-  return items.map((item) => {
-    if (item?.section === 'json') {
-      return { validate: { enable: true } };
-    }
-
-    return null;
-  });
-}
-
-interface ManagedClient {
-  client: LSPClient;
-  lastUsedAt: number;
-  refCount: number;
-  initPromise?: Promise<void>;
-  isInitializing: boolean;
-}
-
-class LSPServerManager {
-  private static instance: LSPServerManager;
-  private clients = new Map<string, ManagedClient>();
-  private cleanupInterval: ReturnType<typeof setInterval> | null = null;
-  private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
-
-  private constructor() {
-    log('[lsp] manager initialized');
-    this.startCleanupTimer();
-    this.registerProcessCleanup();
-  }
-
-  private registerProcessCleanup(): void {
-    const cleanup = () => {
-      for (const [, managed] of this.clients) {
-        try {
-          managed.client.stop();
-        } catch {}
-      }
-      this.clients.clear();
-      if (this.cleanupInterval) {
-        clearInterval(this.cleanupInterval);
-        this.cleanupInterval = null;
-      }
-    };
-
-    process.on('exit', cleanup);
-    process.on('SIGINT', () => {
-      cleanup();
-      process.exit(0);
-    });
-    process.on('SIGTERM', () => {
-      cleanup();
-      process.exit(0);
-    });
-  }
-
-  static getInstance(): LSPServerManager {
-    if (!LSPServerManager.instance) {
-      LSPServerManager.instance = new LSPServerManager();
-    }
-    return LSPServerManager.instance;
-  }
-
-  private getKey(root: string, serverId: string): string {
-    return `${root}::${serverId}`;
-  }
-
-  private startCleanupTimer(): void {
-    if (this.cleanupInterval) return;
-    this.cleanupInterval = setInterval(() => {
-      this.cleanupIdleClients();
-    }, 60000);
-  }
-
-  private cleanupIdleClients(): void {
-    const now = Date.now();
-    for (const [key, managed] of this.clients) {
-      if (
-        managed.refCount === 0 &&
-        now - managed.lastUsedAt > this.IDLE_TIMEOUT
-      ) {
-        managed.client.stop();
-        this.clients.delete(key);
-      }
-    }
-  }
-
-  async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
-    const key = this.getKey(root, server.id);
-
-    const managed = this.clients.get(key);
-    if (managed) {
-      if (managed.initPromise) {
-        log('[lsp] getClient: waiting for init', { key, server: server.id });
-        await managed.initPromise;
-      }
-      if (managed.client.isAlive()) {
-        managed.refCount++;
-        managed.lastUsedAt = Date.now();
-        log('[lsp] getClient: reuse pooled client', {
-          key,
-          server: server.id,
-          refCount: managed.refCount,
-        });
-        return managed.client;
-      }
-      log('[lsp] getClient: client dead, recreating', {
-        key,
-        server: server.id,
-      });
-      await managed.client.stop();
-      this.clients.delete(key);
-    }
-
-    log('[lsp] getClient: creating new client', {
-      key,
-      server: server.id,
-      root,
-    });
-    const client = new LSPClient(root, server);
-    const initPromise = (async () => {
-      await client.start();
-      await client.initialize();
-    })();
-
-    this.clients.set(key, {
-      client,
-      lastUsedAt: Date.now(),
-      refCount: 1,
-      initPromise,
-      isInitializing: true,
-    });
-
-    try {
-      await initPromise;
-      const m = this.clients.get(key);
-      if (m) {
-        m.initPromise = undefined;
-        m.isInitializing = false;
-      }
-      log('[lsp] getClient: client ready', { key, server: server.id });
-    } catch (err) {
-      log('[lsp] getClient: init failed', {
-        key,
-        server: server.id,
-        error: String(err),
-      });
-      this.clients.delete(key);
-      throw err;
-    }
-
-    return client;
-  }
-
-  releaseClient(root: string, serverId: string): void {
-    const key = this.getKey(root, serverId);
-    const managed = this.clients.get(key);
-    if (managed && managed.refCount > 0) {
-      managed.refCount--;
-      managed.lastUsedAt = Date.now();
-      log('[lsp] releaseClient', {
-        key,
-        server: serverId,
-        refCount: managed.refCount,
-      });
-    }
-  }
-
-  isServerInitializing(root: string, serverId: string): boolean {
-    const key = this.getKey(root, serverId);
-    const managed = this.clients.get(key);
-    return managed?.isInitializing ?? false;
-  }
-
-  async stopAll(): Promise<void> {
-    log('[lsp] stopAll: shutting down all clients', {
-      count: this.clients.size,
-    });
-    for (const [key, managed] of this.clients) {
-      await managed.client.stop();
-      log('[lsp] stopAll: client stopped', { key });
-    }
-    this.clients.clear();
-    if (this.cleanupInterval) {
-      clearInterval(this.cleanupInterval);
-      this.cleanupInterval = null;
-    }
-    log('[lsp] stopAll: complete');
-  }
-}
-
-export const lspManager = LSPServerManager.getInstance();
-
-export class LSPClient {
-  private proc: ChildProcess | null = null;
-  private connection: MessageConnection | null = null;
-  private openedFiles = new Set<string>();
-  private stderrBuffer: string[] = [];
-  private processExited = false;
-  private diagnosticsStore = new Map<string, Diagnostic[]>();
-  private diagnosticResultIds = new Map<string, string>();
-  private documents = new Map<
-    string,
-    { version: number; text: string; languageId: string }
-  >();
-  private diagnosticProvider: DiagnosticProviderCapabilities | null = null;
-  private publishDiagnosticsObserved = false;
-  private supportsPullDiagnostics = false;
-  private workspaceConfigurationRequested = false;
-
-  constructor(
-    private root: string,
-    private server: ResolvedServer,
-  ) {}
-
-  async start(): Promise<void> {
-    const command = resolveServerCommand(this.server.command, this.root);
-    if (!command) {
-      throw new Error(
-        `Failed to resolve LSP server command: ${this.server.command.join(' ')}`,
-      );
-    }
-
-    log('[lsp] LSPClient.start: spawning server', {
-      server: this.server.id,
-      command: command.join(' '),
-      root: this.root,
-    });
-
-    this.proc = nodeSpawn(command[0], command.slice(1), {
-      stdio: ['pipe', 'pipe', 'pipe'],
-      cwd: this.root,
-      env: { ...process.env, ...this.server.env },
-    });
-
-    if (!this.proc) {
-      throw new Error(
-        `Failed to spawn LSP server: ${this.server.command.join(' ')}`,
-      );
-    }
-
-    this.startStderrReading();
-
-    // Create JSON-RPC connection
-    const stdout = this.proc.stdout;
-    const stdin = this.proc.stdin;
-    if (!stdout || !stdin) {
-      throw new Error('LSP server process missing stdio streams');
-    }
-    this.connection = createMessageConnection(
-      new StreamMessageReader(stdout),
-      new StreamMessageWriter(stdin),
-    );
-
-    this.connection.onNotification(
-      'textDocument/publishDiagnostics',
-      (params: { uri?: string; diagnostics?: Diagnostic[] }) => {
-        if (!this.publishDiagnosticsObserved) {
-          this.publishDiagnosticsObserved = true;
-          log('[lsp] diagnostics capabilities: publishDiagnostics observed', {
-            server: this.server.id,
-            ...getDiagnosticsCapabilitySummary({
-              diagnosticProvider: this.diagnosticProvider,
-              publishDiagnosticsObserved: this.publishDiagnosticsObserved,
-              workspaceConfigurationRequested:
-                this.workspaceConfigurationRequested,
-            }),
-          });
-        }
-        if (params.uri) {
-          this.diagnosticsStore.set(params.uri, params.diagnostics ?? []);
-        }
-      },
-    );
-
-    this.connection.onRequest(
-      'workspace/configuration',
-      (params: { items?: unknown[] }) => {
-        if (!this.workspaceConfigurationRequested) {
-          this.workspaceConfigurationRequested = true;
-          log(
-            '[lsp] diagnostics capabilities: workspace configuration requested',
-            {
-              server: this.server.id,
-              sections: (params.items ?? []).map((item) =>
-                item && typeof item === 'object' && 'section' in item
-                  ? ((item as { section?: string }).section ?? null)
-                  : null,
-              ),
-              ...getDiagnosticsCapabilitySummary({
-                diagnosticProvider: this.diagnosticProvider,
-                publishDiagnosticsObserved: this.publishDiagnosticsObserved,
-                workspaceConfigurationRequested:
-                  this.workspaceConfigurationRequested,
-              }),
-            },
-          );
-        }
-        return getWorkspaceConfiguration(
-          (params.items ?? []) as Array<{ section?: string } | undefined>,
-        );
-      },
-    );
-
-    this.connection.onRequest('client/registerCapability', () => null);
-    this.connection.onRequest('window/workDoneProgress/create', () => null);
-
-    this.connection.onClose(() => {
-      this.processExited = true;
-    });
-
-    this.connection.listen();
-
-    await new Promise((resolve) => setTimeout(resolve, 100));
-
-    if (this.proc.exitCode !== null) {
-      const stderr = this.stderrBuffer.join('\n');
-      log('[lsp] LSPClient.start: server exited immediately', {
-        server: this.server.id,
-        exitCode: this.proc.exitCode,
-        stderr: stderr.slice(0, 500),
-      });
-      throw new Error(
-        `LSP server exited immediately with code ${this.proc.exitCode}` +
-          (stderr ? `\nstderr: ${stderr}` : ''),
-      );
-    }
-    log('[lsp] LSPClient.start: server spawned', { server: this.server.id });
-  }
-
-  private startStderrReading(): void {
-    if (!this.proc?.stderr) return;
-    this.proc.stderr.on('data', (chunk: Buffer) => {
-      this.stderrBuffer.push(chunk.toString());
-      if (this.stderrBuffer.length > 100) {
-        this.stderrBuffer.shift();
-      }
-    });
-  }
-
-  async initialize(): Promise<void> {
-    if (!this.connection) throw new Error('LSP connection not established');
-
-    log('[lsp] LSPClient.initialize: sending initialize request', {
-      server: this.server.id,
-      root: this.root,
-    });
-
-    const rootUri = pathToFileURL(this.root).href;
-    const result = await withTimeout(
-      this.connection.sendRequest('initialize', {
-        processId: process.pid,
-        rootUri,
-        rootPath: this.root,
-        workspaceFolders: [{ uri: rootUri, name: 'workspace' }],
-        capabilities: {
-          textDocument: {
-            diagnostic: {},
-            hover: { contentFormat: ['markdown', 'plaintext'] },
-            definition: { linkSupport: true },
-            references: {},
-            documentSymbol: { hierarchicalDocumentSymbolSupport: true },
-            publishDiagnostics: {},
-            rename: {
-              prepareSupport: true,
-              prepareSupportDefaultBehavior: 1,
-              honorsChangeAnnotations: true,
-            },
-          },
-          workspace: {
-            symbol: {},
-            workspaceFolders: true,
-            configuration: true,
-            applyEdit: true,
-            workspaceEdit: { documentChanges: true },
-          },
-        },
-        ...this.server.initialization,
-      }),
-      LSP_TIMEOUTS.request,
-      `LSP initialize (${this.server.id})`,
-    );
-
-    const capabilities =
-      result &&
-      typeof result === 'object' &&
-      'capabilities' in result &&
-      result.capabilities &&
-      typeof result.capabilities === 'object'
-        ? result.capabilities
-        : undefined;
-
-    this.diagnosticProvider =
-      capabilities && 'diagnosticProvider' in capabilities
-        ? (capabilities.diagnosticProvider as DiagnosticProviderCapabilities)
-        : null;
-    this.supportsPullDiagnostics = Boolean(this.diagnosticProvider);
-
-    log('[lsp] diagnostics capabilities negotiated', {
-      server: this.server.id,
-      diagnosticProvider: this.diagnosticProvider,
-      ...getDiagnosticsCapabilitySummary({
-        diagnosticProvider: this.diagnosticProvider,
-        publishDiagnosticsObserved: this.publishDiagnosticsObserved,
-        workspaceConfigurationRequested: this.workspaceConfigurationRequested,
-      }),
-    });
-
-    this.connection.sendNotification('initialized', {});
-    await new Promise((r) => setTimeout(r, LSP_TIMEOUTS.initializeDelay));
-    log('[lsp] LSPClient.initialize: complete', { server: this.server.id });
-  }
-
-  private async waitForPublishedDiagnostics(
-    uri: string,
-    timeoutMs = LSP_TIMEOUTS.diagnostics,
-  ): Promise<Diagnostic[] | undefined> {
-    const cachedDiagnostics = this.diagnosticsStore.get(uri);
-    if (cachedDiagnostics) {
-      return cachedDiagnostics;
-    }
-
-    const startedAt = Date.now();
-    while (Date.now() - startedAt < timeoutMs) {
-      await new Promise((r) => setTimeout(r, 100));
-      const diagnostics = this.diagnosticsStore.get(uri);
-      if (diagnostics) {
-        return diagnostics;
-      }
-    }
-
-    return this.diagnosticsStore.get(uri);
-  }
-
-  async openFile(filePath: string): Promise<void> {
-    await this.ensureDocumentSynced(filePath);
-  }
-
-  private async ensureDocumentSynced(filePath: string): Promise<void> {
-    const absPath = resolve(filePath);
-    const uri = pathToFileURL(absPath).href;
-
-    const text = readFileSync(absPath, 'utf-8');
-    const ext = extname(absPath);
-    const languageId = getLanguageId(ext);
-
-    const existing = this.documents.get(uri);
-
-    if (!existing) {
-      log('[lsp] ensureDocumentSynced: didOpen', {
-        filePath: absPath,
-        languageId,
-        size: text.length,
-      });
-      this.connection?.sendNotification('textDocument/didOpen', {
-        textDocument: { uri, languageId, version: 1, text },
-      });
-      this.documents.set(uri, { version: 1, text, languageId });
-      this.openedFiles.add(absPath);
-      // allow server to settle
-      await new Promise((r) => setTimeout(r, LSP_TIMEOUTS.openFileDelay));
-      return;
-    }
-
-    if (existing.text !== text) {
-      const newVersion = existing.version + 1;
-      log('[lsp] ensureDocumentSynced: didChange', {
-        filePath: absPath,
-        languageId,
-        oldVersion: existing.version,
-        newVersion,
-        size: text.length,
-      });
-      this.connection?.sendNotification('textDocument/didChange', {
-        textDocument: { uri, version: newVersion },
-        contentChanges: [{ text }],
-      });
-      this.documents.set(uri, { version: newVersion, text, languageId });
-      // Invalidate cached publishDiagnostics so we wait for fresh results
-      this.diagnosticsStore.delete(uri);
-      this.diagnosticResultIds.delete(uri);
-      // allow server to settle after change
-      await new Promise((r) => setTimeout(r, LSP_TIMEOUTS.openFileDelay));
-    } else {
-      log('[lsp] ensureDocumentSynced: already synced', { filePath: absPath });
-    }
-  }
-
-  async definition(
-    filePath: string,
-    line: number,
-    character: number,
-  ): Promise<unknown> {
-    const absPath = resolve(filePath);
-    await this.openFile(absPath);
-    return this.connection
-      ? withTimeout(
-          this.connection.sendRequest('textDocument/definition', {
-            textDocument: { uri: pathToFileURL(absPath).href },
-            position: { line: line - 1, character },
-          }),
-          LSP_TIMEOUTS.request,
-          `LSP definition (${this.server.id})`,
-        )
-      : undefined;
-  }
-
-  async references(
-    filePath: string,
-    line: number,
-    character: number,
-    includeDeclaration = true,
-  ): Promise<unknown> {
-    const absPath = resolve(filePath);
-    await this.openFile(absPath);
-    return this.connection
-      ? withTimeout(
-          this.connection.sendRequest('textDocument/references', {
-            textDocument: { uri: pathToFileURL(absPath).href },
-            position: { line: line - 1, character },
-            context: { includeDeclaration },
-          }),
-          LSP_TIMEOUTS.request,
-          `LSP references (${this.server.id})`,
-        )
-      : undefined;
-  }
-
-  async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
-    const absPath = resolve(filePath);
-    const uri = pathToFileURL(absPath).href;
-    const startedAt = Date.now();
-    await this.openFile(absPath);
-    await new Promise((r) => setTimeout(r, LSP_TIMEOUTS.diagnosticSettleDelay));
-
-    log('[lsp] diagnostics mode selected', {
-      server: this.server.id,
-      filePath: absPath,
-      activeMode: this.supportsPullDiagnostics ? 'pull' : 'push',
-      ...getDiagnosticsCapabilitySummary({
-        diagnosticProvider: this.diagnosticProvider,
-        publishDiagnosticsObserved: this.publishDiagnosticsObserved,
-        workspaceConfigurationRequested: this.workspaceConfigurationRequested,
-      }),
-    });
-
-    if (this.supportsPullDiagnostics) {
-      try {
-        const result = this.connection
-          ? await withTimeout(
-              this.connection.sendRequest('textDocument/diagnostic', {
-                textDocument: { uri },
-                previousResultId: this.diagnosticResultIds.get(uri),
-              }),
-              LSP_TIMEOUTS.diagnostics,
-              `LSP diagnostics (${this.server.id})`,
-            )
-          : undefined;
-
-        const report = result as DocumentDiagnosticReport | undefined;
-        if (report?.kind === 'full') {
-          if (report.resultId) {
-            this.diagnosticResultIds.set(uri, report.resultId);
-          } else {
-            this.diagnosticResultIds.delete(uri);
-          }
-          this.diagnosticsStore.set(uri, report.items);
-          return { items: report.items };
-        }
-
-        if (report?.kind === 'unchanged') {
-          if (report.resultId) {
-            this.diagnosticResultIds.set(uri, report.resultId);
-          }
-          return { items: this.diagnosticsStore.get(uri) ?? [] };
-        }
-
-        if (result && typeof result === 'object' && 'items' in result) {
-          const legacyResult = result as { items: Diagnostic[] };
-          this.diagnosticsStore.set(uri, legacyResult.items);
-          return legacyResult;
-        }
-      } catch (error) {
-        log('[lsp] diagnostics: falling back to cached publishDiagnostics', {
-          server: this.server.id,
-          error: String(error),
-        });
-      }
-    }
-
-    const elapsed = Date.now() - startedAt;
-    const remainingTimeout = Math.max(LSP_TIMEOUTS.diagnostics - elapsed, 0);
-    const cachedDiagnostics = await this.waitForPublishedDiagnostics(
-      uri,
-      remainingTimeout,
-    );
-    if (cachedDiagnostics) {
-      return { items: cachedDiagnostics };
-    }
-
-    throw new Error(
-      `Unable to retrieve diagnostics from ${this.server.id}: request timed out or is unsupported.`,
-    );
-  }
-
-  async rename(
-    filePath: string,
-    line: number,
-    character: number,
-    newName: string,
-  ): Promise<unknown> {
-    const absPath = resolve(filePath);
-    await this.openFile(absPath);
-    return this.connection
-      ? withTimeout(
-          this.connection.sendRequest('textDocument/rename', {
-            textDocument: { uri: pathToFileURL(absPath).href },
-            position: { line: line - 1, character },
-            newName,
-          }),
-          LSP_TIMEOUTS.request,
-          `LSP rename (${this.server.id})`,
-        )
-      : undefined;
-  }
-
-  isAlive(): boolean {
-    return (
-      this.proc !== null && !this.processExited && this.proc.exitCode === null
-    );
-  }
-
-  async stop(): Promise<void> {
-    log('[lsp] LSPClient.stop: stopping', { server: this.server.id });
-    try {
-      if (this.connection) {
-        await withTimeout(
-          this.connection.sendRequest('shutdown'),
-          1_000,
-          `LSP shutdown (${this.server.id})`,
-        );
-        this.connection.sendNotification('exit');
-        this.connection.dispose();
-      }
-    } catch {}
-    this.proc?.kill();
-    this.proc = null;
-    this.connection = null;
-    this.processExited = true;
-    this.diagnosticProvider = null;
-    this.publishDiagnosticsObserved = false;
-    this.supportsPullDiagnostics = false;
-    this.workspaceConfigurationRequested = false;
-    this.diagnosticsStore.clear();
-    this.diagnosticResultIds.clear();
-    this.documents.clear();
-    log('[lsp] LSPClient.stop: complete', { server: this.server.id });
-  }
-}

+ 0 - 29
src/tools/lsp/codemap.md

@@ -1,29 +0,0 @@
-# src/tools/lsp/
-
-## Responsibility
-
-- Encapsulate a minimal LSP client stack so the CLI can ask language servers for definitions, references, diagnostics, and renames without managing subprocesses itself.
-- Provide thin, documented tools (`lsp_goto_definition`, `lsp_find_references`, `lsp_diagnostics`, `lsp_rename`) that compose `withLspClient` helpers with exported plugin schema definitions (see `src/tools/lsp/tools.ts`).
-
-## Design
-
-- `LSPServerManager` in `src/tools/lsp/client.ts` is a singleton connection pool that keys clients by `<workspace-root>::<server-id>`, tracks reference counts, and evicts idle or dead processes.
-- `LSPClient` wears responsibility for spawning the underlying language server (`bun.spawn`), wiring vscode-jsonrpc streams, maintaining opened documents, collecting diagnostics notifications, and gracefully shutting down/ restarting when needed.
-- Utility helpers in `src/tools/lsp/utils.ts` keep formatting, severity mapping, diagnostic filtering, workspace root discovery, URI translation, and workspace-edit application consolidated; they also host `withLspClient`, which orchestrates server lookup, client acquisition/release, and retry messaging when initialization is still in progress.
-- Constants/configuration (`src/tools/lsp/constants.ts`, `src/tools/lsp/config.ts`) define the supported servers, extension-to-language mappings, install hints, and runtime checks for whether the configured binaries exist, so the tools never start a missing server.
-- Shared types from `src/tools/lsp/types.ts` mirror the vscode-languageserver-protocol definitions that both the client and tools consume.
-
-## Flow
-
-1. Tool execution (e.g., `lsp_find_references`) calls `withLspClient` with a file path; `withLspClient` resolves the extension using `findServerForExtension` and either throws an install/configuration error or proceeds.
-2. `withLspClient` asks `lspManager` for a client tied to the workspace root; the manager either reuses an existing `LSPClient` or starts/initializes a new one, waiting on the init promise before handing it back, and increments the reference count.
-3. `LSPClient` ensures the file is open via `textDocument/didOpen` (loading the text, waiting for the server) before sending requests such as `textDocument/definition`, `references`, `diagnostic`, or `rename`; diagnostics request also waits for pending `publishDiagnostics` notifications when the server cannot answer directly.
-4. Responses flow back through `vscode-jsonrpc` and are formatted/filtered by `utils` (`formatLocation`, `filterDiagnosticsBySeverity`, `formatDiagnostic`, etc.), and in the rename path the returned `WorkspaceEdit` is applied locally, with results reported via `formatApplyResult`.
-5. After the tool receives its answer, `withLspClient` releases the client reference; the manager later tears down idle clients or when the process exits.
-
-## Integration
-
-- `src/tools/lsp/index.ts` re-exports the manager and the defined tools/types so other pieces of the CLI can import the LSP surface without touching implementation details.
-- Tools are wired into the plugin layer via `@opencode-ai/plugin/tool`; the exported `ToolDefinition` instances declare arguments, descriptions, and error formatting using helpers from `src/tools/lsp/utils.ts`.
-- `client.ts` depends on `config.ts` and `constants.ts` for language IDs, server configuration, and install hints; `utils.ts` depends on the same modules for severity maps, `findServerForExtension`, and `lspManager`.
-- External callers (e.g., command handlers) simply feed absolute paths and cursor positions into the exported tools; the module reports installation errors, server initialization delays, or successful results back through formatted strings so higher layers can relay them to the user.

+ 0 - 416
src/tools/lsp/config-store.test.ts

@@ -1,416 +0,0 @@
-import { beforeEach, describe, expect, it } from 'bun:test';
-
-describe('LSP Config Store', () => {
-  let setUserLspConfig: (config: Record<string, unknown> | undefined) => void;
-  let getUserLspConfig: (
-    serverId: string,
-  ) => import('./config-store').UserLspConfig | undefined;
-  let getAllUserLspConfigs: () => Map<
-    string,
-    import('./config-store').UserLspConfig
-  >;
-  let hasUserLspConfig: () => boolean;
-
-  beforeEach(async () => {
-    // Import fresh module and clear state
-    const module = await import('./config-store');
-    setUserLspConfig = module.setUserLspConfig;
-    getUserLspConfig = module.getUserLspConfig;
-    getAllUserLspConfigs = module.getAllUserLspConfigs;
-    hasUserLspConfig = module.hasUserLspConfig;
-    setUserLspConfig(undefined);
-  });
-
-  describe('setUserLspConfig', () => {
-    it('clears the store when called with undefined', () => {
-      // First set some config
-      setUserLspConfig({
-        'typescript-language-server': {
-          command: ['typescript-language-server', '--stdio'],
-        },
-      });
-
-      expect(hasUserLspConfig()).toBe(true);
-
-      // Clear with undefined
-      setUserLspConfig(undefined);
-
-      expect(hasUserLspConfig()).toBe(false);
-    });
-
-    it('clears the store when called with empty object', () => {
-      // First set some config
-      setUserLspConfig({
-        'typescript-language-server': {
-          command: ['typescript-language-server', '--stdio'],
-        },
-      });
-
-      expect(hasUserLspConfig()).toBe(true);
-
-      // Clear with empty object
-      setUserLspConfig({});
-
-      expect(hasUserLspConfig()).toBe(false);
-    });
-
-    it('sets multiple servers correctly', () => {
-      const config = {
-        'typescript-language-server': {
-          command: ['typescript-language-server', '--stdio'],
-          extensions: ['ts', 'tsx'],
-        },
-        'eslint-language-server': {
-          command: ['vscode-eslint-language-server', '--stdio'],
-          extensions: ['js', 'jsx'],
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const tsConfig = getUserLspConfig('typescript-language-server');
-      const eslintConfig = getUserLspConfig('eslint-language-server');
-
-      expect(tsConfig).toBeDefined();
-      expect(tsConfig?.id).toBe('typescript-language-server');
-      expect(tsConfig?.command).toEqual([
-        'typescript-language-server',
-        '--stdio',
-      ]);
-      expect(tsConfig?.extensions).toEqual(['ts', 'tsx']);
-
-      expect(eslintConfig).toBeDefined();
-      expect(eslintConfig?.id).toBe('eslint-language-server');
-      expect(eslintConfig?.command).toEqual([
-        'vscode-eslint-language-server',
-        '--stdio',
-      ]);
-      expect(eslintConfig?.extensions).toEqual(['js', 'jsx']);
-    });
-
-    it('handles all UserLspConfig fields', () => {
-      const config = {
-        'test-server': {
-          command: ['test-server', '--stdio'],
-          extensions: ['ts', 'js'],
-          disabled: true,
-          env: { NODE_ENV: 'test', DEBUG: 'true' },
-          initialization: { maxNumberOfProblems: 100 },
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const serverConfig = getUserLspConfig('test-server');
-
-      expect(serverConfig).toBeDefined();
-      expect(serverConfig?.id).toBe('test-server');
-      expect(serverConfig?.command).toEqual(['test-server', '--stdio']);
-      expect(serverConfig?.extensions).toEqual(['ts', 'js']);
-      expect(serverConfig?.disabled).toBe(true);
-      expect(serverConfig?.env).toEqual({ NODE_ENV: 'test', DEBUG: 'true' });
-      expect(serverConfig?.initialization).toEqual({
-        maxNumberOfProblems: 100,
-      });
-    });
-
-    it('handles optional fields correctly', () => {
-      const config = {
-        'minimal-server': {
-          command: ['minimal-server'],
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const serverConfig = getUserLspConfig('minimal-server');
-
-      expect(serverConfig).toBeDefined();
-      expect(serverConfig?.id).toBe('minimal-server');
-      expect(serverConfig?.command).toEqual(['minimal-server']);
-      expect(serverConfig?.extensions).toBeUndefined();
-      expect(serverConfig?.disabled).toBeUndefined();
-      expect(serverConfig?.env).toBeUndefined();
-      expect(serverConfig?.initialization).toBeUndefined();
-    });
-
-    it('ignores non-object entries', () => {
-      const config = {
-        'valid-server': {
-          command: ['valid-server'],
-        },
-        'invalid-null': null,
-        'invalid-string': 'not-an-object',
-        'invalid-number': 123,
-      };
-
-      setUserLspConfig(config);
-
-      expect(hasUserLspConfig()).toBe(true);
-      expect(getUserLspConfig('valid-server')).toBeDefined();
-      expect(getUserLspConfig('invalid-null')).toBeUndefined();
-      expect(getUserLspConfig('invalid-string')).toBeUndefined();
-      expect(getUserLspConfig('invalid-number')).toBeUndefined();
-    });
-
-    it('replaces existing config when called again', () => {
-      // Set initial config
-      setUserLspConfig({
-        'old-server': {
-          command: ['old-server'],
-        },
-      });
-
-      expect(getAllUserLspConfigs().size).toBe(1);
-
-      // Replace with new config
-      setUserLspConfig({
-        'new-server': {
-          command: ['new-server'],
-        },
-      });
-
-      expect(getAllUserLspConfigs().size).toBe(1);
-      expect(getUserLspConfig('old-server')).toBeUndefined();
-      expect(getUserLspConfig('new-server')).toBeDefined();
-    });
-  });
-
-  describe('getUserLspConfig', () => {
-    it('returns undefined for non-existent server', () => {
-      const config = getUserLspConfig('non-existent-server');
-
-      expect(config).toBeUndefined();
-    });
-
-    it('returns correct config for existing server', () => {
-      const config = {
-        'typescript-language-server': {
-          command: ['typescript-language-server', '--stdio'],
-          extensions: ['ts', 'tsx'],
-          disabled: false,
-          env: { NODE_ENV: 'production' },
-          initialization: { maxNumberOfProblems: 100 },
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const serverConfig = getUserLspConfig('typescript-language-server');
-
-      expect(serverConfig).toBeDefined();
-      expect(serverConfig?.id).toBe('typescript-language-server');
-      expect(serverConfig?.command).toEqual([
-        'typescript-language-server',
-        '--stdio',
-      ]);
-      expect(serverConfig?.extensions).toEqual(['ts', 'tsx']);
-      expect(serverConfig?.disabled).toBe(false);
-      expect(serverConfig?.env).toEqual({ NODE_ENV: 'production' });
-      expect(serverConfig?.initialization).toEqual({
-        maxNumberOfProblems: 100,
-      });
-    });
-
-    it('returns reference - mutation affects store', () => {
-      const config = {
-        'test-server': {
-          command: ['test-server'],
-          extensions: ['ts'],
-          env: { KEY: 'value' },
-          initialization: { setting: 'original' },
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const serverConfig = getUserLspConfig('test-server');
-
-      expect(serverConfig).toBeDefined();
-
-      // Mutate the returned config
-      if (serverConfig) {
-        serverConfig.command = ['modified-command'];
-        serverConfig.extensions = ['js'];
-        if (serverConfig.env) {
-          serverConfig.env.KEY = 'modified';
-        }
-        if (serverConfig.initialization) {
-          serverConfig.initialization.setting = 'modified';
-        }
-      }
-
-      // Get fresh copy - should be affected (same reference)
-      const freshConfig = getUserLspConfig('test-server');
-
-      expect(freshConfig?.command).toEqual(['modified-command']);
-      expect(freshConfig?.extensions).toEqual(['js']);
-      expect(freshConfig?.env).toEqual({ KEY: 'modified' });
-      expect(freshConfig?.initialization).toEqual({ setting: 'modified' });
-    });
-  });
-
-  describe('getAllUserLspConfigs', () => {
-    it('returns empty map when no config set', () => {
-      const allConfigs = getAllUserLspConfigs();
-
-      expect(allConfigs).toBeInstanceOf(Map);
-      expect(allConfigs.size).toBe(0);
-    });
-
-    it('returns all configured servers', () => {
-      const config = {
-        'server-1': {
-          command: ['server-1'],
-          extensions: ['ts'],
-        },
-        'server-2': {
-          command: ['server-2'],
-          extensions: ['js'],
-        },
-        'server-3': {
-          command: ['server-3'],
-          extensions: ['py'],
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const allConfigs = getAllUserLspConfigs();
-
-      expect(allConfigs.size).toBe(3);
-      expect(allConfigs.get('server-1')?.id).toBe('server-1');
-      expect(allConfigs.get('server-2')?.id).toBe('server-2');
-      expect(allConfigs.get('server-3')?.id).toBe('server-3');
-    });
-
-    it('returns new Map with same value references - mutation affects store', () => {
-      const config = {
-        'test-server': {
-          command: ['test-server'],
-          extensions: ['ts'],
-          env: { KEY: 'value' },
-          initialization: { setting: 'original' },
-        },
-      };
-
-      setUserLspConfig(config);
-
-      const allConfigs = getAllUserLspConfigs();
-
-      // Mutate the returned map's value
-      const serverConfig = allConfigs.get('test-server');
-      if (serverConfig) {
-        serverConfig.command = ['modified-command'];
-        if (serverConfig.env) {
-          serverConfig.env.KEY = 'modified';
-        }
-      }
-
-      // Get fresh copy - should be affected (same reference)
-      const freshConfig = getUserLspConfig('test-server');
-
-      expect(freshConfig?.command).toEqual(['modified-command']);
-      expect(freshConfig?.env).toEqual({ KEY: 'modified' });
-    });
-
-    it('returns a new Map instance each time', () => {
-      setUserLspConfig({
-        'test-server': {
-          command: ['test-server'],
-        },
-      });
-
-      const map1 = getAllUserLspConfigs();
-      const map2 = getAllUserLspConfigs();
-
-      expect(map1).not.toBe(map2);
-      expect(map1.size).toBe(map2.size);
-    });
-  });
-
-  describe('hasUserLspConfig', () => {
-    it('returns false when store is empty', () => {
-      expect(hasUserLspConfig()).toBe(false);
-    });
-
-    it('returns true when config exists', () => {
-      setUserLspConfig({
-        'test-server': {
-          command: ['test-server'],
-        },
-      });
-
-      expect(hasUserLspConfig()).toBe(true);
-    });
-
-    it('returns false after clearing config', () => {
-      setUserLspConfig({
-        'test-server': {
-          command: ['test-server'],
-        },
-      });
-
-      expect(hasUserLspConfig()).toBe(true);
-
-      setUserLspConfig(undefined);
-
-      expect(hasUserLspConfig()).toBe(false);
-    });
-
-    it('returns true for multiple servers', () => {
-      setUserLspConfig({
-        'server-1': { command: ['server-1'] },
-        'server-2': { command: ['server-2'] },
-        'server-3': { command: ['server-3'] },
-      });
-
-      expect(hasUserLspConfig()).toBe(true);
-    });
-  });
-
-  describe('integration tests', () => {
-    it('handles complete workflow: set, get, check, clear', () => {
-      // Initial state
-      expect(hasUserLspConfig()).toBe(false);
-      expect(getAllUserLspConfigs().size).toBe(0);
-
-      // Set config
-      const config = {
-        'typescript-language-server': {
-          command: ['typescript-language-server', '--stdio'],
-          extensions: ['ts', 'tsx'],
-          disabled: false,
-          env: { NODE_ENV: 'development' },
-          initialization: { maxNumberOfProblems: 100 },
-        },
-        'eslint-language-server': {
-          command: ['vscode-eslint-language-server', '--stdio'],
-          extensions: ['js', 'jsx'],
-        },
-      };
-
-      setUserLspConfig(config);
-
-      // Verify config is set
-      expect(hasUserLspConfig()).toBe(true);
-      expect(getAllUserLspConfigs().size).toBe(2);
-
-      // Get specific server
-      const tsConfig = getUserLspConfig('typescript-language-server');
-      expect(tsConfig?.id).toBe('typescript-language-server');
-      expect(tsConfig?.command).toEqual([
-        'typescript-language-server',
-        '--stdio',
-      ]);
-
-      // Clear config
-      setUserLspConfig(undefined);
-
-      // Verify config is cleared
-      expect(hasUserLspConfig()).toBe(false);
-      expect(getAllUserLspConfigs().size).toBe(0);
-      expect(getUserLspConfig('typescript-language-server')).toBeUndefined();
-    });
-  });
-});

+ 0 - 70
src/tools/lsp/config-store.ts

@@ -1,70 +0,0 @@
-// LSP Config Store - Holds OpenCode's lsp config for runtime access
-// This allows the config hook to set the lsp config once,
-// and the LSP tools to read it at execution time.
-
-/**
- * User-provided LSP server config (from opencode.json lsp section).
- * Fields are optional because user config may not include all properties.
- */
-export interface UserLspConfig {
-  id: string;
-  command?: string[];
-  extensions?: string[];
-  disabled?: boolean;
-  env?: Record<string, string>;
-  initialization?: Record<string, unknown>;
-}
-
-/**
- * Module-level store for OpenCode's lsp configuration.
- * Set during plugin initialization via the config hook.
- */
-const userConfig = new Map<string, UserLspConfig>();
-
-/**
- * Set the user's lsp config from opencode.json.
- * Called during plugin initialization.
- */
-export function setUserLspConfig(
-  config: Record<string, unknown> | undefined,
-): void {
-  userConfig.clear();
-  if (config) {
-    for (const [id, server] of Object.entries(config)) {
-      if (server && typeof server === 'object') {
-        const s = server as Record<string, unknown>;
-        userConfig.set(id, {
-          id,
-          command: s.command as string[] | undefined,
-          extensions: s.extensions as string[] | undefined,
-          disabled: s.disabled as boolean | undefined,
-          env: s.env as Record<string, string> | undefined,
-          initialization: s.initialization as
-            | Record<string, unknown>
-            | undefined,
-        });
-      }
-    }
-  }
-}
-
-/**
- * Get the user's lsp config for a specific server ID.
- */
-export function getUserLspConfig(serverId: string): UserLspConfig | undefined {
-  return userConfig.get(serverId);
-}
-
-/**
- * Get all user-configured lsp servers.
- */
-export function getAllUserLspConfigs(): Map<string, UserLspConfig> {
-  return new Map(userConfig);
-}
-
-/**
- * Check if user has configured any lsp servers.
- */
-export function hasUserLspConfig(): boolean {
-  return userConfig.size > 0;
-}

+ 0 - 185
src/tools/lsp/config.test.ts

@@ -1,185 +0,0 @@
-import {
-  afterEach,
-  beforeEach,
-  describe,
-  expect,
-  mock,
-  spyOn,
-  test,
-} from 'bun:test';
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import { join } from 'node:path';
-
-const whichSyncMock = mock((..._args: unknown[]) => null as string | null);
-mock.module('which', () => ({
-  sync: whichSyncMock,
-  default: { sync: whichSyncMock },
-}));
-
-import { findServerForExtension, isServerInstalled } from './config';
-
-describe('config', () => {
-  beforeEach(() => {
-    spyOn(fs, 'existsSync').mockImplementation(() => false);
-    spyOn(os, 'homedir').mockReturnValue('/home/user');
-    whichSyncMock.mockClear();
-    whichSyncMock.mockReturnValue(null);
-  });
-
-  afterEach(() => {
-    mock.restore();
-  });
-
-  describe('isServerInstalled', () => {
-    test('should return false if command is empty', () => {
-      expect(isServerInstalled([])).toBe(false);
-    });
-
-    test('should detect absolute paths', () => {
-      spyOn(fs, 'existsSync').mockImplementation(
-        (path: fs.PathLike) => path === '/usr/bin/lsp-server',
-      );
-      expect(isServerInstalled(['/usr/bin/lsp-server'])).toBe(true);
-      expect(isServerInstalled(['/usr/bin/missing'])).toBe(false);
-    });
-
-    test('should detect server in PATH', () => {
-      const originalPath = process.env.PATH;
-      process.env.PATH = '/usr/local/bin:/usr/bin';
-
-      whichSyncMock.mockReturnValue(
-        join('/usr/bin', 'typescript-language-server'),
-      );
-
-      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
-
-      process.env.PATH = originalPath;
-    });
-
-    test('should detect server in local node_modules', () => {
-      const cwd = process.cwd();
-      const localBin = join(
-        cwd,
-        'node_modules',
-        '.bin',
-        'typescript-language-server',
-      );
-
-      spyOn(fs, 'existsSync').mockImplementation(
-        (path: fs.PathLike) => path === localBin,
-      );
-
-      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
-    });
-
-    test('should detect server in global opencode bin', () => {
-      const globalBin = join(
-        '/home/user',
-        '.config',
-        'opencode',
-        'bin',
-        'typescript-language-server',
-      );
-
-      whichSyncMock.mockReturnValue(globalBin);
-
-      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
-    });
-  });
-
-  describe('findServerForExtension', () => {
-    test('should skip deno for .ts when project is not a deno workspace', () => {
-      whichSyncMock.mockImplementation((cmd: unknown) =>
-        cmd === 'typescript-language-server'
-          ? join('/usr/bin', 'typescript-language-server')
-          : null,
-      );
-      spyOn(fs, 'existsSync').mockImplementation((path: fs.PathLike) =>
-        path.toString().includes('bun.lock'),
-      );
-
-      const result = findServerForExtension(
-        '.ts',
-        '/workspace/project/src/index.ts',
-      );
-
-      expect(result.status).toBe('found');
-      if (result.status === 'found') {
-        expect(result.server.id).toBe('typescript');
-      }
-    });
-
-    test('should prefer deno for .ts in a deno workspace', () => {
-      whichSyncMock.mockImplementation((cmd: unknown) =>
-        cmd === 'deno' ? join('/usr/bin', 'deno') : null,
-      );
-      spyOn(fs, 'existsSync').mockImplementation((path: fs.PathLike) =>
-        path.toString().includes('deno.json'),
-      );
-
-      const result = findServerForExtension('.ts', '/workspace/app/src/mod.ts');
-
-      expect(result.status).toBe('found');
-      if (result.status === 'found') {
-        expect(result.server.id).toBe('deno');
-      }
-    });
-
-    test('should return found for .py extension if installed (prefers ty)', () => {
-      whichSyncMock.mockImplementation((cmd: unknown) =>
-        cmd === 'ty' ? join('/usr/bin', 'ty') : null,
-      );
-
-      const result = findServerForExtension('.py');
-
-      expect(result.status).toBe('found');
-      if (result.status === 'found') {
-        expect(result.server.id).toBe('ty');
-      }
-    });
-
-    test('should return not_configured for unknown extension', () => {
-      const result = findServerForExtension('.unknown');
-      expect(result.status).toBe('not_configured');
-    });
-
-    test('should continue to later matching servers when earlier ones are unavailable', () => {
-      whichSyncMock.mockImplementation((cmd: unknown) =>
-        cmd === 'typescript-language-server'
-          ? join('/usr/bin', 'typescript-language-server')
-          : null,
-      );
-      spyOn(fs, 'existsSync').mockImplementation((path: fs.PathLike) =>
-        path.toString().includes('bun.lock'),
-      );
-
-      const result = findServerForExtension(
-        '.ts',
-        '/workspace/project/src/index.ts',
-      );
-
-      expect(result.status).toBe('found');
-      if (result.status === 'found') {
-        expect(result.server.id).toBe('typescript');
-      }
-    });
-
-    test('should return first applicable not_installed server if no match is launchable', () => {
-      spyOn(fs, 'existsSync').mockImplementation((path: fs.PathLike) =>
-        path.toString().includes('bun.lock'),
-      );
-
-      const result = findServerForExtension(
-        '.ts',
-        '/workspace/project/src/index.ts',
-      );
-
-      expect(result.status).toBe('not_installed');
-      if (result.status === 'not_installed') {
-        expect(result.server.id).toBe('typescript');
-        expect(result.installHint).toContain('typescript-language-server');
-      }
-    });
-  });
-});

+ 0 - 252
src/tools/lsp/config.ts

@@ -1,252 +0,0 @@
-// Simplified LSP config - uses OpenCode's lsp config from opencode.json
-// Falls back to BUILTIN_SERVERS if no user config exists
-
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import { dirname, join, resolve } from 'node:path';
-import whichSync from 'which';
-import { log } from '../../utils';
-import { getAllUserLspConfigs, hasUserLspConfig } from './config-store';
-import {
-  BUILTIN_SERVERS,
-  LANGUAGE_EXTENSIONS,
-  LSP_INSTALL_HINTS,
-} from './constants';
-import type { ResolvedServer, ServerLookupResult } from './types';
-
-/**
- * Merged server config that combines built-in and user config.
- */
-interface MergedServerConfig {
-  id: string;
-  command: string[];
-  extensions: string[];
-  root?: (file: string) => string | undefined;
-  env?: Record<string, string>;
-  initialization?: Record<string, unknown>;
-}
-
-/**
- * Build the merged server list by combining built-in servers with user config.
- * This mirrors OpenCode core's pattern: start with built-in, then merge user config.
- */
-function buildMergedServers(): Map<string, MergedServerConfig> {
-  const servers = new Map<string, MergedServerConfig>();
-
-  // Start with built-in servers
-  for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
-    servers.set(id, {
-      id,
-      command: config.command,
-      extensions: config.extensions,
-      root: config.root,
-      env: config.env,
-      initialization: config.initialization,
-    });
-  }
-
-  // Apply user config (merge with existing or add new)
-  if (hasUserLspConfig()) {
-    for (const [id, userConfig] of getAllUserLspConfigs()) {
-      // Handle disabled: remove built-in from consideration
-      if (userConfig.disabled === true) {
-        servers.delete(id);
-        continue;
-      }
-
-      const existing = servers.get(id);
-
-      if (existing) {
-        // Merge user config with built-in, preserving root function from built-in
-        servers.set(id, {
-          ...existing,
-          id,
-          // User config overrides command if provided
-          command: userConfig.command ?? existing.command,
-          // User config overrides extensions if provided
-          extensions: userConfig.extensions ?? existing.extensions,
-          // Preserve root function from built-in (not overrideable)
-          root: existing.root,
-          // User config overrides env/initialization
-          env: userConfig.env ?? existing.env,
-          initialization: userConfig.initialization ?? existing.initialization,
-        });
-      } else {
-        // New server defined by user config
-        servers.set(id, {
-          id,
-          command: userConfig.command ?? [],
-          extensions: userConfig.extensions ?? [],
-          root: undefined,
-          env: userConfig.env,
-          initialization: userConfig.initialization,
-        });
-      }
-    }
-  }
-
-  return servers;
-}
-
-function getServerWorkspace(
-  config: MergedServerConfig,
-  filePath?: string,
-): string | undefined {
-  if (!filePath) {
-    return undefined;
-  }
-
-  if (!config.root) {
-    return dirname(resolve(filePath));
-  }
-
-  return config.root(filePath);
-}
-
-function shouldSkipServer(
-  config: MergedServerConfig,
-  filePath?: string,
-): boolean {
-  if (!filePath) {
-    return false;
-  }
-
-  return (
-    config.id === 'deno' && getServerWorkspace(config, filePath) === undefined
-  );
-}
-
-function toResolvedServer(
-  config: MergedServerConfig,
-  command?: string[],
-): ResolvedServer {
-  return {
-    id: config.id,
-    command: command ?? config.command,
-    extensions: config.extensions,
-    root: config.root,
-    env: config.env,
-    initialization: config.initialization,
-  };
-}
-
-function findInstalledServer(
-  configs: MergedServerConfig[],
-  filePath?: string,
-): ServerLookupResult | undefined {
-  let firstNotInstalled: Extract<
-    ServerLookupResult,
-    { status: 'not_installed' }
-  > | null = null;
-
-  for (const config of configs) {
-    const workspace = getServerWorkspace(config, filePath);
-    const resolvedCommand = resolveServerCommand(
-      config.command,
-      workspace ?? (filePath ? dirname(resolve(filePath)) : undefined),
-    );
-    const server = toResolvedServer(config, resolvedCommand ?? undefined);
-
-    log(
-      `[LSP] Considering server for ${config.extensions.join(', ')}: ${config.id} with command ${config.command.join(' ')}`,
-    );
-
-    if (resolvedCommand) {
-      return { status: 'found', server };
-    }
-
-    if (!firstNotInstalled) {
-      log(`[LSP] Server ${config.id} not found in PATH or local node_modules`);
-      firstNotInstalled = {
-        status: 'not_installed',
-        server,
-        installHint:
-          LSP_INSTALL_HINTS[config.id] ||
-          `Install '${config.command[0]}' and add to PATH`,
-      };
-    }
-  }
-
-  return firstNotInstalled ?? undefined;
-}
-
-export function findServerForExtension(
-  ext: string,
-  filePath?: string,
-): ServerLookupResult {
-  const servers = [...buildMergedServers().values()].filter((config) =>
-    config.extensions.includes(ext),
-  );
-
-  if (servers.length === 0) {
-    log(`[LSP] No server config found for ${ext}`);
-    return { status: 'not_configured', extension: ext };
-  }
-
-  const candidateServers = servers.filter(
-    (config) => !shouldSkipServer(config, filePath),
-  );
-
-  if (candidateServers.length === 0) {
-    log(`[LSP] No applicable server config found for ${ext} at ${filePath}`);
-    return { status: 'not_configured', extension: ext };
-  }
-
-  const result = findInstalledServer(candidateServers, filePath);
-
-  if (result) {
-    return result;
-  }
-
-  log(`[LSP] No applicable server config found for ${ext}`);
-  return { status: 'not_configured', extension: ext };
-}
-
-export function getLanguageId(ext: string): string {
-  return LANGUAGE_EXTENSIONS[ext] || 'plaintext';
-}
-
-export function resolveServerCommand(
-  command: string[],
-  cwd?: string,
-): string[] | null {
-  if (command.length === 0) return null;
-
-  const [cmd, ...args] = command;
-
-  if (cmd.includes('/') || cmd.includes('\\')) {
-    return fs.existsSync(cmd) ? command : null;
-  }
-
-  const isWindows = process.platform === 'win32';
-  const ext = isWindows ? '.exe' : '';
-
-  const opencodeBin = join(os.homedir(), '.config', 'opencode', 'bin');
-  const searchPath =
-    (process.env.PATH ?? '') + (isWindows ? ';' : ':') + opencodeBin;
-
-  const result = whichSync.sync(cmd, {
-    path: searchPath,
-    pathExt: isWindows ? process.env.PATHEXT : undefined,
-    nothrow: true,
-  });
-
-  if (result !== null) {
-    return [result, ...args];
-  }
-
-  const localBinRoot = cwd ?? process.cwd();
-  const localBin = join(localBinRoot, 'node_modules', '.bin', cmd);
-  if (fs.existsSync(localBin)) {
-    return [localBin, ...args];
-  }
-  if (fs.existsSync(localBin + ext)) {
-    return [localBin + ext, ...args];
-  }
-
-  return null;
-}
-
-export function isServerInstalled(command: string[]): boolean {
-  return resolveServerCommand(command) !== null;
-}

+ 0 - 103
src/tools/lsp/constants.test.ts

@@ -1,103 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import {
-  DEFAULT_MAX_DIAGNOSTICS,
-  DEFAULT_MAX_REFERENCES,
-  SEVERITY_MAP,
-  SYMBOL_KIND_MAP,
-} from './constants';
-
-describe('constants', () => {
-  describe('SYMBOL_KIND_MAP', () => {
-    test('should have correct number of symbol kinds', () => {
-      expect(Object.keys(SYMBOL_KIND_MAP).length).toBe(26);
-    });
-
-    test('should map File to 1', () => {
-      expect(SYMBOL_KIND_MAP[1]).toBe('File');
-    });
-
-    test('should map Module to 2', () => {
-      expect(SYMBOL_KIND_MAP[2]).toBe('Module');
-    });
-
-    test('should map Class to 5', () => {
-      expect(SYMBOL_KIND_MAP[5]).toBe('Class');
-    });
-
-    test('should map Method to 6', () => {
-      expect(SYMBOL_KIND_MAP[6]).toBe('Method');
-    });
-
-    test('should map Function to 12', () => {
-      expect(SYMBOL_KIND_MAP[12]).toBe('Function');
-    });
-
-    test('should map Variable to 13', () => {
-      expect(SYMBOL_KIND_MAP[13]).toBe('Variable');
-    });
-
-    test('should map Constant to 14', () => {
-      expect(SYMBOL_KIND_MAP[14]).toBe('Constant');
-    });
-
-    test('should map TypeParameter to 26', () => {
-      expect(SYMBOL_KIND_MAP[26]).toBe('TypeParameter');
-    });
-  });
-
-  describe('SEVERITY_MAP', () => {
-    test('should have correct number of severity levels', () => {
-      expect(Object.keys(SEVERITY_MAP).length).toBe(4);
-    });
-
-    test('should map 1 to error', () => {
-      expect(SEVERITY_MAP[1]).toBe('error');
-    });
-
-    test('should map 2 to warning', () => {
-      expect(SEVERITY_MAP[2]).toBe('warning');
-    });
-
-    test('should map 3 to information', () => {
-      expect(SEVERITY_MAP[3]).toBe('information');
-    });
-
-    test('should map 4 to hint', () => {
-      expect(SEVERITY_MAP[4]).toBe('hint');
-    });
-  });
-
-  describe('DEFAULT_MAX_REFERENCES', () => {
-    test('should be 200', () => {
-      expect(DEFAULT_MAX_REFERENCES).toBe(200);
-    });
-  });
-
-  describe('DEFAULT_MAX_DIAGNOSTICS', () => {
-    test('should be 200', () => {
-      expect(DEFAULT_MAX_DIAGNOSTICS).toBe(200);
-    });
-  });
-});
-
-// Note: NearestRoot tests require complex file system mocking
-// and are better suited for integration tests with real directory structures.
-// The constants themselves (maps and default values) are tested above.
-describe('NearestRoot', () => {
-  test('should be exported as a function', () => {
-    const { NearestRoot } = require('./constants');
-    expect(typeof NearestRoot).toBe('function');
-  });
-
-  test('should return a function when called', () => {
-    const { NearestRoot } = require('./constants');
-    const rootFn = NearestRoot(['package.json']);
-    expect(typeof rootFn).toBe('function');
-  });
-
-  test('should accept optional exclude patterns', () => {
-    const { NearestRoot } = require('./constants');
-    const rootFn = NearestRoot(['package.json'], ['deno.json']);
-    expect(typeof rootFn).toBe('function');
-  });
-});

+ 0 - 780
src/tools/lsp/constants.ts

@@ -1,780 +0,0 @@
-// LSP constants - mirrors OpenCode core servers (no auto-download)
-// All server definitions from OpenCode's LSPServer namespace
-
-import { existsSync, readdirSync, statSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
-import type { LSPServerConfig, RootFunction } from './types';
-
-export const SYMBOL_KIND_MAP: Record<number, string> = {
-  1: 'File',
-  2: 'Module',
-  3: 'Namespace',
-  4: 'Package',
-  5: 'Class',
-  6: 'Method',
-  7: 'Property',
-  8: 'Field',
-  9: 'Constructor',
-  10: 'Enum',
-  11: 'Interface',
-  12: 'Function',
-  13: 'Variable',
-  14: 'Constant',
-  15: 'String',
-  16: 'Number',
-  17: 'Boolean',
-  18: 'Array',
-  19: 'Object',
-  20: 'Key',
-  21: 'Null',
-  22: 'EnumMember',
-  23: 'Struct',
-  24: 'Event',
-  25: 'Operator',
-  26: 'TypeParameter',
-};
-
-export const SEVERITY_MAP: Record<number, string> = {
-  1: 'error',
-  2: 'warning',
-  3: 'information',
-  4: 'hint',
-};
-
-export const DEFAULT_MAX_REFERENCES = 200;
-export const DEFAULT_MAX_DIAGNOSTICS = 200;
-
-// Common root patterns shared by multiple servers
-const LOCK_FILE_PATTERNS = [
-  'package-lock.json',
-  'bun.lockb',
-  'bun.lock',
-  'pnpm-lock.yaml',
-  'yarn.lock',
-];
-
-/**
- * Generator that walks up the directory tree yielding each directory.
- * Mirrors OpenCode core's Filesystem.up() utility.
- */
-function* walkUpDirectories(
-  start: string,
-  stop: string,
-): Generator<string, void, unknown> {
-  let dir = resolve(start);
-
-  // If start is a file (not a directory), start from its parent
-  try {
-    if (!statSync(dir).isDirectory()) {
-      dir = dirname(dir);
-    }
-  } catch {
-    dir = dirname(dir);
-  }
-
-  let prevDir = '';
-  while (dir !== prevDir && dir !== '/') {
-    yield dir;
-    prevDir = dir;
-    if (dir === stop) break;
-    dir = dirname(dir);
-  }
-}
-
-/**
- * NearestRoot helper - mirrors OpenCode core's NearestRoot function.
- * Creates a RootFunction that walks up directories looking for root markers.
- */
-export function NearestRoot(
-  includePatterns: string[],
-  excludePatterns?: string[],
-): RootFunction {
-  return (file: string): string | undefined => {
-    const cwd = process.cwd();
-
-    // Check for exclusion patterns first
-    if (excludePatterns) {
-      for (const dir of walkUpDirectories(file, cwd)) {
-        for (const pattern of excludePatterns) {
-          if (existsSync(`${dir}/${pattern}`)) {
-            // Found an exclusion marker - this server should not activate
-            return undefined;
-          }
-        }
-      }
-    }
-
-    // Find nearest root pattern
-    for (const dir of walkUpDirectories(file, cwd)) {
-      for (const pattern of includePatterns) {
-        if (pattern.includes('*')) {
-          // Handle glob patterns (e.g., *.xcodeproj, *.tf)
-          try {
-            const entries = readdirSync(dir);
-            const regex = new RegExp(
-              `^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`,
-            );
-            if (entries.some((entry) => regex.test(entry))) {
-              return dir;
-            }
-          } catch {
-            // Skip directories that can't be read
-          }
-        } else if (existsSync(`${dir}/${pattern}`)) {
-          return dir;
-        }
-      }
-    }
-
-    // No root found - return undefined (let caller decide fallback)
-    return undefined;
-  };
-}
-
-/**
- * Built-in LSP servers - mirrors OpenCode core LSPServer namespace.
- * User configuration from opencode.json lsp section takes precedence and is
- * merged on top of these: user settings override command/extensions/env, while
- * root patterns are always preserved from built-in. Servers can be removed by
- * setting `"disabled": true` in the user config.
- */
-export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, 'id'>> = {
-  // ============ JavaScript/TypeScript Ecosystem ============
-
-  deno: {
-    command: ['deno', 'lsp'],
-    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
-    root: NearestRoot(['deno.json', 'deno.jsonc']),
-  },
-
-  typescript: {
-    command: ['typescript-language-server', '--stdio'],
-    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'],
-    root: NearestRoot(LOCK_FILE_PATTERNS, ['deno.json', 'deno.jsonc']),
-  },
-
-  vue: {
-    command: ['vue-language-server', '--stdio'],
-    extensions: ['.vue'],
-    root: NearestRoot(LOCK_FILE_PATTERNS),
-  },
-
-  eslint: {
-    command: ['vscode-eslint-language-server', '--stdio'],
-    extensions: [
-      '.ts',
-      '.tsx',
-      '.js',
-      '.jsx',
-      '.mjs',
-      '.cjs',
-      '.mts',
-      '.cts',
-      '.vue',
-    ],
-    root: NearestRoot(LOCK_FILE_PATTERNS),
-  },
-
-  oxlint: {
-    command: ['oxlint', '--lsp'],
-    extensions: [
-      '.ts',
-      '.tsx',
-      '.js',
-      '.jsx',
-      '.mjs',
-      '.cjs',
-      '.mts',
-      '.cts',
-      '.vue',
-      '.astro',
-      '.svelte',
-    ],
-    root: NearestRoot([
-      '.oxlintrc.json',
-      ...LOCK_FILE_PATTERNS,
-      'package.json',
-    ]),
-  },
-
-  biome: {
-    command: ['biome', 'lsp-proxy', '--stdio'],
-    extensions: [
-      '.ts',
-      '.tsx',
-      '.js',
-      '.jsx',
-      '.mjs',
-      '.cjs',
-      '.mts',
-      '.cts',
-      '.json',
-      '.jsonc',
-      '.vue',
-      '.astro',
-      '.svelte',
-      '.css',
-      '.graphql',
-      '.gql',
-      '.html',
-    ],
-    root: NearestRoot(['biome.json', 'biome.jsonc', ...LOCK_FILE_PATTERNS]),
-  },
-
-  // ============ Backend Languages ============
-
-  gopls: {
-    command: ['gopls'],
-    extensions: ['.go'],
-    root: NearestRoot(['go.work', 'go.mod', 'go.sum']),
-  },
-
-  ruby_lsp: {
-    // Note: uses rubocop --lsp (RuboCop's built-in LSP linting mode),
-    // not the separate ruby-lsp gem. Users wanting full ruby-lsp features
-    // should configure: { command: ["ruby-lsp"] }
-    command: ['rubocop', '--lsp'],
-    extensions: ['.rb', '.rake', '.gemspec', '.ru'],
-    root: NearestRoot(['Gemfile']),
-  },
-
-  ty: {
-    command: ['ty', 'server'],
-    extensions: ['.py', '.pyi'],
-    root: NearestRoot([
-      'pyproject.toml',
-      'ty.toml',
-      'setup.py',
-      'setup.cfg',
-      'requirements.txt',
-      'Pipfile',
-      'pyrightconfig.json',
-    ]),
-  },
-
-  pyright: {
-    command: ['pyright-langserver', '--stdio'],
-    extensions: ['.py', '.pyi'],
-    root: NearestRoot([
-      'pyproject.toml',
-      'setup.py',
-      'setup.cfg',
-      'requirements.txt',
-      'Pipfile',
-      'pyrightconfig.json',
-    ]),
-  },
-
-  elixir_ls: {
-    command: ['elixir-ls'],
-    extensions: ['.ex', '.exs'],
-    root: NearestRoot(['mix.exs', 'mix.lock']),
-  },
-
-  zls: {
-    command: ['zls'],
-    extensions: ['.zig', '.zon'],
-    root: NearestRoot(['build.zig']),
-  },
-
-  // ============ .NET Languages ============
-
-  csharp: {
-    command: ['csharp-ls'],
-    extensions: ['.cs'],
-    root: NearestRoot(['.slnx', '.sln', '.csproj', 'global.json']),
-  },
-
-  fsharp: {
-    command: ['fsautocomplete'],
-    extensions: ['.fs', '.fsi', '.fsx', '.fsscript'],
-    root: NearestRoot(['.slnx', '.sln', '.fsproj', 'global.json']),
-  },
-
-  // ============ Apple Languages ============
-
-  sourcekit_lsp: {
-    command: ['sourcekit-lsp'],
-    extensions: ['.swift', '.objc', '.objcpp'],
-    root: NearestRoot(['Package.swift', '*.xcodeproj', '*.xcworkspace']),
-  },
-
-  // ============ Rust ============
-
-  rust: {
-    command: ['rust-analyzer'],
-    extensions: ['.rs'],
-    root: NearestRoot(['Cargo.toml', 'Cargo.lock']),
-  },
-
-  // ============ C/C++ ============
-
-  clangd: {
-    command: ['clangd', '--background-index', '--clang-tidy'],
-    extensions: [
-      '.c',
-      '.cpp',
-      '.cc',
-      '.cxx',
-      '.c++',
-      '.h',
-      '.hpp',
-      '.hh',
-      '.hxx',
-      '.h++',
-    ],
-    root: NearestRoot([
-      'compile_commands.json',
-      'compile_flags.txt',
-      '.clangd',
-      'CMakeLists.txt',
-      'Makefile',
-    ]),
-  },
-
-  // ============ Frontend Frameworks ============
-
-  svelte: {
-    command: ['svelteserver', '--stdio'],
-    extensions: ['.svelte'],
-    root: NearestRoot(LOCK_FILE_PATTERNS),
-  },
-
-  astro: {
-    command: ['astro-ls', '--stdio'],
-    extensions: ['.astro'],
-    root: NearestRoot(LOCK_FILE_PATTERNS),
-  },
-
-  // ============ Java/JVM Languages ============
-
-  jdtls: {
-    // Complex java -jar invocation - requires special setup
-    // Users should configure their own command for JDTLS
-    command: ['jdtls'],
-    extensions: ['.java'],
-    root: NearestRoot([
-      'pom.xml',
-      'build.gradle',
-      'build.gradle.kts',
-      '.project',
-      '.classpath',
-    ]),
-  },
-
-  kotlin_ls: {
-    command: ['kotlin-lsp', '--stdio'],
-    extensions: ['.kt', '.kts'],
-    root: NearestRoot([
-      'settings.gradle.kts',
-      'settings.gradle',
-      'gradlew',
-      'build.gradle.kts',
-      'build.gradle',
-      'pom.xml',
-    ]),
-  },
-
-  // ============ Config/ Markup Languages ============
-
-  yaml_ls: {
-    command: ['yaml-language-server', '--stdio'],
-    extensions: ['.yaml', '.yml'],
-    root: NearestRoot(LOCK_FILE_PATTERNS),
-  },
-
-  lua_ls: {
-    command: ['lua-language-server'],
-    extensions: ['.lua'],
-    root: NearestRoot([
-      '.luarc.json',
-      '.luarc.jsonc',
-      '.luacheckrc',
-      'stylua.toml',
-      'selene.toml',
-      'selene.yml',
-    ]),
-  },
-
-  php_intelephense: {
-    command: ['intelephense', '--stdio'],
-    extensions: ['.php'],
-    root: NearestRoot(['composer.json', 'composer.lock', '.php-version']),
-  },
-
-  prisma: {
-    command: ['prisma', 'language-server'],
-    extensions: ['.prisma'],
-    root: NearestRoot(['schema.prisma', 'prisma/schema.prisma', 'prisma']),
-  },
-
-  dart: {
-    command: ['dart', 'language-server', '--lsp'],
-    extensions: ['.dart'],
-    root: NearestRoot(['pubspec.yaml', 'analysis_options.yaml']),
-  },
-
-  ocaml_lsp: {
-    command: ['ocamllsp'],
-    extensions: ['.ml', '.mli'],
-    root: NearestRoot(['dune-project', 'dune-workspace', '.merlin', 'opam']),
-  },
-
-  // ============ Shell/Scripts ============
-
-  bash: {
-    command: ['bash-language-server', 'start'],
-    extensions: ['.sh', '.bash', '.zsh', '.ksh'],
-    root: undefined, // No root detection needed - uses file directory
-  },
-
-  // ============ Infrastructure/ DevOps ============
-
-  terraform_ls: {
-    command: ['terraform-ls', 'serve'],
-    extensions: ['.tf', '.tfvars'],
-    root: NearestRoot(['.terraform.lock.hcl', 'terraform.tfstate', '*.tf']),
-  },
-
-  // ============ Document/ Publishing ============
-
-  texlab: {
-    command: ['texlab'],
-    extensions: ['.tex', '.bib'],
-    root: NearestRoot(['.latexmkrc', 'latexmkrc', '.texlabroot', 'texlabroot']),
-  },
-
-  dockerfile: {
-    command: ['docker-langserver', '--stdio'],
-    extensions: ['.dockerfile', 'Dockerfile'],
-    root: undefined, // No root detection needed - uses file directory
-  },
-
-  // ============ Functional Languages ============
-
-  gleam: {
-    command: ['gleam', 'lsp'],
-    extensions: ['.gleam'],
-    root: NearestRoot(['gleam.toml']),
-  },
-
-  clojure_lsp: {
-    command: ['clojure-lsp', 'listen'],
-    extensions: ['.clj', '.cljs', '.cljc', '.edn'],
-    root: NearestRoot([
-      'deps.edn',
-      'project.clj',
-      'shadow-cljs.edn',
-      'bb.edn',
-      'build.boot',
-    ]),
-  },
-
-  nixd: {
-    command: ['nixd'],
-    extensions: ['.nix'],
-    root: NearestRoot(['flake.nix']),
-  },
-
-  tinymist: {
-    command: ['tinymist'],
-    extensions: ['.typ', '.typc'],
-    root: NearestRoot(['typst.toml']),
-  },
-
-  haskell_language_server: {
-    command: ['haskell-language-server-wrapper', '--lsp'],
-    extensions: ['.hs', '.lhs'],
-    root: NearestRoot(['stack.yaml', 'cabal.project', 'hie.yaml', '*.cabal']),
-  },
-
-  julials: {
-    command: [
-      'julia',
-      '--startup-file=no',
-      '--history-file=no',
-      '-e',
-      'using LanguageServer; runserver()',
-    ],
-    extensions: ['.jl'],
-    root: NearestRoot(['Project.toml', 'Manifest.toml', '*.jl']),
-  },
-};
-
-export const LSP_INSTALL_HINTS: Record<string, string> = {
-  // JavaScript/TypeScript Ecosystem
-  deno: 'Install Deno: https://deno.land/#installation',
-  typescript: 'npm install -g typescript-language-server typescript',
-  vue: 'npm install -g @vue/language-server',
-  eslint: 'npm install -g vscode-langservers-extracted',
-  oxlint: 'npm install -g oxlint or install via package manager',
-  biome: 'npm install -g @biomejs/biome',
-
-  // Backend Languages
-  gopls: 'go install golang.org/x/tools/gopls@latest',
-  ruby_lsp: 'gem install rubocop (Ruby LSP runs via rubocop --lsp)',
-  ty: 'pip install ty or see https://github.com/astral-sh/ty',
-  pyright: 'pip install pyright',
-  elixir_ls:
-    'Download from https://github.com/elixir-lsp/elixir-ls/releases or build from source',
-  zls: 'Install via your package manager or build from source: https://github.com/zigtools/zls',
-
-  // .NET Languages
-  csharp: 'dotnet tool install --global csharp-ls',
-  fsharp: 'dotnet tool install --global fsautocomplete',
-
-  // Apple Languages
-  sourcekit_lsp: 'Install via Xcode or Swift toolchain (included with Xcode)',
-
-  // Rust
-  rust: 'rustup component add rust-analyzer',
-
-  // C/C++
-  clangd: 'Install clangd via your system package manager or LLVM',
-
-  // Frontend Frameworks
-  svelte: 'npm install -g svelte-language-server',
-  astro: 'npm install -g @astrojs/language-server',
-
-  // Java/JVM Languages
-  jdtls: 'See https://github.com/eclipse-jdtls/eclipse.jdt.ls for installation',
-  kotlin_ls: 'Download from https://github.com/Kotlin/kotlin-lsp/releases',
-
-  // Config/ Markup Languages
-  yaml_ls: 'npm install -g yaml-language-server',
-  lua_ls: 'Download from https://github.com/LuaLS/lua-language-server/releases',
-  php_intelephense: 'npm install -g intelephense',
-  prisma: 'npm install -g @prisma/language-server or use npx',
-  dart: 'dart pub global activate language_server',
-  ocaml_lsp: 'opam install ocaml-lsp-server',
-
-  // Shell/Scripts
-  bash: 'npm install -g bash-language-server',
-
-  // Infrastructure/ DevOps
-  terraform_ls:
-    'Download from https://github.com/hashicorp/terraform-ls/releases or install via tfenv',
-
-  // Document/ Publishing
-  texlab: 'Download from https://github.com/latex-lsp/texlab/releases',
-  dockerfile: 'npm install -g dockerfile-language-server-nodejs',
-
-  // Functional Languages
-  gleam: 'Install Gleam: https://gleam.run/getting-started/',
-  clojure_lsp:
-    'Install via deps.edn, project.clj, or: clj -M -m clojure-lsp.main',
-  nixd: 'Install via nix-env or your system package manager',
-  tinymist: 'cargo install tinymist or download from releases',
-  haskell_language_server:
-    'Install Haskell Tool Stack or Cabal, then language-server',
-  julials: 'Install Julia: https://julialang.org/downloads/',
-};
-
-/**
- * Maps file extensions to LSP language IDs.
- * Mirrors OpenCode core's LANGUAGE_EXTENSIONS constant.
- */
-export const LANGUAGE_EXTENSIONS: Record<string, string> = {
-  // JavaScript/TypeScript
-  '.ts': 'typescript',
-  '.tsx': 'typescriptreact',
-  '.mts': 'typescript',
-  '.cts': 'typescript',
-  '.js': 'javascript',
-  '.jsx': 'javascriptreact',
-  '.mjs': 'javascript',
-  '.cjs': 'javascript',
-  '.ets': 'typescript',
-
-  // Vue
-  '.vue': 'vue',
-
-  // Svelte
-  '.svelte': 'svelte',
-
-  // Astro
-  '.astro': 'astro',
-
-  // HTML/XML
-  '.html': 'html',
-  '.htm': 'html',
-  '.xml': 'xml',
-  '.xsl': 'xsl',
-
-  // CSS/SCSS/Less
-  '.css': 'css',
-  '.scss': 'scss',
-  '.sass': 'sass',
-  '.less': 'less',
-
-  // JSON
-  '.json': 'json',
-  '.jsonc': 'json',
-
-  // GraphQL
-  '.graphql': 'graphql',
-  '.gql': 'graphql',
-
-  // Web/Build
-  '.dockerfile': 'dockerfile',
-  '.sh': 'shellscript',
-  '.bash': 'shellscript',
-  '.zsh': 'shellscript',
-  '.ksh': 'shellscript',
-
-  // Go
-  '.go': 'go',
-
-  // Rust
-  '.rs': 'rust',
-
-  // Python
-  '.py': 'python',
-  '.pyi': 'python',
-
-  // Ruby
-  '.rb': 'ruby',
-  '.rake': 'ruby',
-  '.gemspec': 'ruby',
-  '.ru': 'ruby',
-
-  // C/C++
-  '.c': 'c',
-  '.cpp': 'cpp',
-  '.cc': 'cpp',
-  '.cxx': 'cpp',
-  '.c++': 'cpp',
-  '.h': 'c',
-  '.hpp': 'cpp',
-  '.hh': 'cpp',
-  '.hxx': 'cpp',
-  '.h++': 'cpp',
-
-  // Java
-  '.java': 'java',
-
-  // Kotlin
-  '.kt': 'kotlin',
-  '.kts': 'kotlin',
-
-  // C#
-  '.cs': 'csharp',
-
-  // F#
-  '.fs': 'fsharp',
-  '.fsi': 'fsharp',
-  '.fsx': 'fsharp',
-  '.fsscript': 'fsharp',
-
-  // Swift/Objective-C
-  '.swift': 'swift',
-  '.m': 'objective-c',
-  '.mm': 'objective-cpp',
-
-  // Zig
-  '.zig': 'zig',
-  '.zon': 'zig',
-
-  // Elixir
-  '.ex': 'elixir',
-  '.exs': 'elixir',
-
-  // Clojure
-  '.clj': 'clojure',
-  '.cljs': 'clojure',
-  '.cljc': 'clojure',
-  '.edn': 'clojure',
-
-  // Haskell
-  '.hs': 'haskell',
-  '.lhs': 'haskell',
-
-  // OCaml
-  '.ml': 'ocaml',
-  '.mli': 'ocaml',
-
-  // Scala
-  '.scala': 'scala',
-
-  // PHP
-  '.php': 'php',
-
-  // Lua
-  '.lua': 'lua',
-
-  // Dart
-  '.dart': 'dart',
-
-  // YAML
-  '.yaml': 'yaml',
-  '.yml': 'yaml',
-
-  // Terraform
-  '.tf': 'terraform',
-  '.tfvars': 'terraform-vars',
-  '.hcl': 'hcl',
-
-  // Nix
-  '.nix': 'nix',
-
-  // Typst
-  '.typ': 'typst',
-  '.typc': 'typst',
-
-  // LaTeX
-  '.tex': 'latex',
-  '.latex': 'latex',
-  '.bib': 'bibtex',
-  '.bibtex': 'bibtex',
-
-  // Prisma
-  '.prisma': 'prisma',
-
-  // Julia
-  '.jl': 'julia',
-
-  // Gleam
-  '.gleam': 'gleam',
-
-  // Markdown
-  '.md': 'markdown',
-  '.markdown': 'markdown',
-
-  // Other
-  '.d': 'd',
-  '.pas': 'pascal',
-  '.pascal': 'pascal',
-  '.diff': 'diff',
-  '.patch': 'diff',
-  '.erl': 'erlang',
-  '.hrl': 'erlang',
-  '.groovy': 'groovy',
-  '.handlebars': 'handlebars',
-  '.hbs': 'handlebars',
-  '.ini': 'ini',
-  '.makefile': 'makefile',
-  makefile: 'makefile',
-  '.pug': 'jade',
-  '.jade': 'jade',
-  '.r': 'r',
-  '.cshtml': 'razor',
-  '.razor': 'razor',
-  '.erb': 'erb',
-  '.html.erb': 'erb',
-  '.js.erb': 'erb',
-  '.css.erb': 'erb',
-  '.json.erb': 'erb',
-  '.shader': 'shaderlab',
-  '.sql': 'sql',
-  '.perl': 'perl',
-  '.pl': 'perl',
-  '.pm': 'perl',
-  '.pm6': 'perl6',
-  '.ps1': 'powershell',
-  '.psm1': 'powershell',
-  '.coffee': 'coffeescript',
-  '.bat': 'bat',
-  '.abap': 'abap',
-  '.gitcommit': 'git-commit',
-  '.gitrebase': 'git-rebase',
-};

+ 0 - 19
src/tools/lsp/index.ts

@@ -1,19 +0,0 @@
-// LSP Module - Explicit exports
-
-export { lspManager } from './client';
-export { getUserLspConfig, setUserLspConfig } from './config-store';
-export {
-  lsp_diagnostics,
-  lsp_find_references,
-  lsp_goto_definition,
-  lsp_rename,
-} from './tools';
-
-// Re-export types for external use
-export type {
-  Diagnostic,
-  Location,
-  LSPServerConfig,
-  ResolvedServer,
-  WorkspaceEdit,
-} from './types';

+ 0 - 178
src/tools/lsp/tools.ts

@@ -1,178 +0,0 @@
-// LSP Tools - 4 essential tools only
-
-import { type ToolDefinition, tool } from '@opencode-ai/plugin/tool';
-import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES } from './constants';
-import type {
-  Diagnostic,
-  Location,
-  LocationLink,
-  WorkspaceEdit,
-} from './types';
-import {
-  applyWorkspaceEdit,
-  filterDiagnosticsBySeverity,
-  formatApplyResult,
-  formatDiagnostic,
-  formatLocation,
-  withLspClient,
-} from './utils';
-
-const formatError = (e: unknown): string =>
-  `Error: ${e instanceof Error ? e.message : String(e)}`;
-
-export const lsp_goto_definition: ToolDefinition = tool({
-  description: 'Jump to symbol definition. Find WHERE something is defined.',
-  args: {
-    filePath: tool.schema.string().describe('Absolute path to the file'),
-    line: tool.schema.number().min(1).describe('1-based line number'),
-    character: tool.schema.number().min(0).describe('0-based character offset'),
-  },
-  execute: async (args) => {
-    try {
-      const result = await withLspClient(args.filePath, async (client) => {
-        return (await client.definition(
-          args.filePath,
-          args.line,
-          args.character,
-        )) as Location | Location[] | LocationLink[] | null;
-      });
-
-      if (!result) {
-        return 'No definition found';
-      }
-
-      const locations = Array.isArray(result) ? result : [result];
-      if (locations.length === 0) {
-        return 'No definition found';
-      }
-
-      return locations.map(formatLocation).join('\n');
-    } catch (e) {
-      return formatError(e);
-    }
-  },
-});
-
-export const lsp_find_references: ToolDefinition = tool({
-  description:
-    'Find ALL usages/references of a symbol across the entire workspace.',
-  args: {
-    filePath: tool.schema.string().describe('Absolute path to the file'),
-    line: tool.schema.number().min(1).describe('1-based line number'),
-    character: tool.schema.number().min(0).describe('0-based character offset'),
-    includeDeclaration: tool.schema
-      .boolean()
-      .optional()
-      .describe('Include the declaration itself'),
-  },
-  execute: async (args) => {
-    try {
-      const result = await withLspClient(args.filePath, async (client) => {
-        return (await client.references(
-          args.filePath,
-          args.line,
-          args.character,
-          args.includeDeclaration ?? true,
-        )) as Location[] | null;
-      });
-
-      if (!result || result.length === 0) {
-        return 'No references found';
-      }
-
-      const total = result.length;
-      const truncated = total > DEFAULT_MAX_REFERENCES;
-      const limited = truncated
-        ? result.slice(0, DEFAULT_MAX_REFERENCES)
-        : result;
-      const lines = limited.map(formatLocation);
-      if (truncated) {
-        lines.unshift(
-          `Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`,
-        );
-      }
-      return lines.join('\n');
-    } catch (e) {
-      return formatError(e);
-    }
-  },
-});
-
-export const lsp_diagnostics: ToolDefinition = tool({
-  description:
-    'Get errors, warnings, hints from language server BEFORE running build.',
-  args: {
-    filePath: tool.schema.string().describe('Absolute path to the file'),
-    severity: tool.schema
-      .enum(['error', 'warning', 'information', 'hint', 'all'])
-      .optional()
-      .describe('Filter by severity level'),
-  },
-  execute: async (args) => {
-    try {
-      const result = await withLspClient(args.filePath, async (client) => {
-        return (await client.diagnostics(args.filePath)) as
-          | { items?: Diagnostic[] }
-          | Diagnostic[]
-          | null;
-      });
-
-      let diagnostics: Diagnostic[] = [];
-      if (result) {
-        if (Array.isArray(result)) {
-          diagnostics = result;
-        } else if (result.items) {
-          diagnostics = result.items;
-        }
-      }
-
-      diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity);
-
-      if (diagnostics.length === 0) {
-        return 'No diagnostics found';
-      }
-
-      const total = diagnostics.length;
-      const truncated = total > DEFAULT_MAX_DIAGNOSTICS;
-      const limited = truncated
-        ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS)
-        : diagnostics;
-      const lines = limited.map(formatDiagnostic);
-      if (truncated) {
-        lines.unshift(
-          `Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`,
-        );
-      }
-      return lines.join('\n');
-    } catch (e) {
-      return formatError(e);
-    }
-  },
-});
-
-export const lsp_rename: ToolDefinition = tool({
-  description:
-    'Rename symbol across entire workspace. APPLIES changes to all files.',
-  args: {
-    filePath: tool.schema.string().describe('Absolute path to the file'),
-    line: tool.schema.number().min(1).describe('1-based line number'),
-    character: tool.schema.number().min(0).describe('0-based character offset'),
-    newName: tool.schema.string().describe('New symbol name'),
-  },
-  execute: async (args) => {
-    try {
-      const edit = await withLspClient(args.filePath, async (client) => {
-        return (await client.rename(
-          args.filePath,
-          args.line,
-          args.character,
-          args.newName,
-        )) as WorkspaceEdit | null;
-      });
-      const result = applyWorkspaceEdit(edit);
-      return formatApplyResult(result);
-    } catch (e) {
-      return formatError(e);
-    }
-  },
-});

+ 0 - 80
src/tools/lsp/types.ts

@@ -1,80 +0,0 @@
-import type {
-  CreateFile,
-  DeleteFile,
-  Diagnostic,
-  DocumentSymbol,
-  Location,
-  LocationLink,
-  Position,
-  Range,
-  RenameFile,
-  SymbolInformation as SymbolInfo,
-  TextDocumentEdit,
-  TextDocumentIdentifier,
-  TextEdit,
-  VersionedTextDocumentIdentifier,
-  WorkspaceEdit,
-} from 'vscode-languageserver-protocol';
-
-/**
- * Root function type - mirrors OpenCode core's RootFunction.
- * Returns the project root directory for a given file, or undefined if not applicable.
- */
-export type RootFunction = (file: string) => string | undefined;
-
-export interface LSPServerConfig {
-  id: string;
-  command: string[];
-  extensions: string[];
-  root?: RootFunction;
-  disabled?: boolean;
-  env?: Record<string, string>;
-  initialization?: Record<string, unknown>;
-}
-
-export interface ResolvedServer {
-  id: string;
-  command: string[];
-  extensions: string[];
-  root?: RootFunction;
-  env?: Record<string, string>;
-  initialization?: Record<string, unknown>;
-}
-
-export type ServerLookupResult =
-  | { status: 'found'; server: ResolvedServer }
-  | { status: 'not_configured'; extension: string }
-  | { status: 'not_installed'; server: ResolvedServer; installHint: string };
-
-export type {
-  CreateFile,
-  DeleteFile,
-  Diagnostic,
-  DocumentSymbol,
-  Location,
-  LocationLink,
-  Position,
-  Range,
-  RenameFile,
-  SymbolInfo,
-  TextDocumentEdit,
-  TextDocumentIdentifier,
-  TextEdit,
-  VersionedTextDocumentIdentifier,
-  WorkspaceEdit,
-};
-
-export interface DocumentDiagnosticReportFull {
-  kind: 'full';
-  items: Diagnostic[];
-  resultId?: string;
-}
-
-export interface DocumentDiagnosticReportUnchanged {
-  kind: 'unchanged';
-  resultId?: string;
-}
-
-export type DocumentDiagnosticReport =
-  | DocumentDiagnosticReportFull
-  | DocumentDiagnosticReportUnchanged;

+ 0 - 222
src/tools/lsp/utils.test.ts

@@ -1,222 +0,0 @@
-import {
-  afterEach,
-  beforeEach,
-  describe,
-  expect,
-  mock,
-  spyOn,
-  test,
-} from 'bun:test';
-import * as fs from 'node:fs';
-
-import {
-  applyWorkspaceEdit,
-  filterDiagnosticsBySeverity,
-  formatApplyResult,
-  formatDiagnostic,
-  formatLocation,
-  formatSeverity,
-  uriToPath,
-} from './utils';
-
-describe('utils', () => {
-  beforeEach(() => {
-    spyOn(fs, 'readFileSync').mockImplementation((() => '') as any);
-    spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
-    spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
-    spyOn(fs, 'existsSync').mockImplementation(() => true);
-    spyOn(fs, 'statSync').mockImplementation((() => ({
-      isDirectory: () => false,
-    })) as any);
-  });
-
-  afterEach(() => {
-    mock.restore();
-  });
-
-  describe('uriToPath', () => {
-    test('should convert file URI to path', () => {
-      const uri = 'file:///home/user/project/file.ts';
-      const path = uriToPath(uri);
-      expect(path).toContain('home');
-      expect(path).toContain('file.ts');
-    });
-  });
-
-  describe('formatLocation', () => {
-    test('should format Location object', () => {
-      const loc = {
-        uri: 'file:///home/user/test.ts',
-        range: {
-          start: { line: 9, character: 5 },
-          end: { line: 9, character: 10 },
-        },
-      };
-      const formatted = formatLocation(loc);
-      expect(formatted).toContain('test.ts:10:5');
-    });
-  });
-
-  describe('formatSeverity', () => {
-    test('should map severity numbers to strings', () => {
-      expect(formatSeverity(1)).toBe('error');
-      expect(formatSeverity(2)).toBe('warning');
-      expect(formatSeverity(3)).toBe('information');
-      expect(formatSeverity(4)).toBe('hint');
-      expect(formatSeverity(undefined)).toBe('unknown');
-    });
-  });
-
-  describe('formatDiagnostic', () => {
-    test('should format diagnostic correctly', () => {
-      const diag = {
-        severity: 1,
-        range: {
-          start: { line: 0, character: 0 },
-          end: { line: 0, character: 5 },
-        },
-        message: 'Unexpected token',
-        source: 'eslint',
-        code: 'no-unused-vars',
-      };
-      const formatted = formatDiagnostic(diag as any);
-      expect(formatted).toBe(
-        'error[eslint] (no-unused-vars) at 1:0: Unexpected token',
-      );
-    });
-  });
-
-  describe('filterDiagnosticsBySeverity', () => {
-    const diags = [
-      { severity: 1, message: 'e1' },
-      { severity: 2, message: 'w1' },
-    ] as any[];
-
-    test('should filter by error', () => {
-      const filtered = filterDiagnosticsBySeverity(diags, 'error');
-      expect(filtered).toHaveLength(1);
-      expect(filtered[0].severity).toBe(1);
-    });
-  });
-
-  describe('applyWorkspaceEdit', () => {
-    test('should apply single file edit', () => {
-      const uri = 'file:///test.ts';
-      const filePath = uriToPath(uri);
-      spyOn(fs, 'readFileSync').mockReturnValue('line1\nline2\nline3' as any);
-
-      const edit = {
-        changes: {
-          [uri]: [
-            {
-              range: {
-                start: { line: 1, character: 0 },
-                end: { line: 1, character: 5 },
-              },
-              newText: 'replaced',
-            },
-          ],
-        },
-      };
-
-      const result = applyWorkspaceEdit(edit as any);
-      expect(result.success).toBe(true);
-      expect(result.filesModified).toContain(filePath);
-      expect(fs.writeFileSync).toHaveBeenCalled();
-    });
-
-    test('should handle overlapping edits by sorting them in reverse order', () => {
-      const uri = 'file:///test.ts';
-      spyOn(fs, 'readFileSync').mockReturnValue('abcde' as any);
-
-      const edit = {
-        changes: {
-          [uri]: [
-            {
-              range: {
-                start: { line: 0, character: 0 },
-                end: { line: 0, character: 1 },
-              },
-              newText: '1',
-            },
-            {
-              range: {
-                start: { line: 0, character: 2 },
-                end: { line: 0, character: 3 },
-              },
-              newText: '3',
-            },
-          ],
-        },
-      };
-
-      const result = applyWorkspaceEdit(edit as any);
-      expect(result.success).toBe(true);
-      const writtenContent = (fs.writeFileSync as any).mock.calls[0][1];
-      expect(writtenContent).toBe('1b3de');
-    });
-
-    test('should handle create file operation', () => {
-      const edit = {
-        documentChanges: [{ kind: 'create', uri: 'file:///new.ts' }],
-      };
-
-      const result = applyWorkspaceEdit(edit as any);
-      expect(result.success).toBe(true);
-      expect(fs.writeFileSync).toHaveBeenCalledWith(
-        uriToPath('file:///new.ts'),
-        '',
-        'utf-8',
-      );
-    });
-
-    test('should handle rename file operation', () => {
-      const oldUri = 'file:///old.ts';
-      const newUri = 'file:///new.ts';
-      spyOn(fs, 'readFileSync').mockReturnValue('some content' as any);
-
-      const edit = {
-        documentChanges: [{ kind: 'rename', oldUri, newUri }],
-      };
-
-      const result = applyWorkspaceEdit(edit as any);
-      expect(result.success).toBe(true);
-      expect(fs.writeFileSync).toHaveBeenCalledWith(
-        uriToPath(newUri),
-        'some content',
-        'utf-8',
-      );
-      expect(fs.unlinkSync).toHaveBeenCalledWith(uriToPath(oldUri));
-    });
-
-    test('should handle delete file operation', () => {
-      const uri = 'file:///delete.ts';
-      const edit = {
-        documentChanges: [{ kind: 'delete', uri }],
-      };
-
-      const result = applyWorkspaceEdit(edit as any);
-      expect(result.success).toBe(true);
-      expect(fs.unlinkSync).toHaveBeenCalledWith(uriToPath(uri));
-    });
-
-    test('should return error if no edit provided', () => {
-      const result = applyWorkspaceEdit(null);
-      expect(result.success).toBe(false);
-      expect(result.errors).toContain('No edit provided');
-    });
-  });
-
-  describe('formatApplyResult', () => {
-    test('should format successful result', () => {
-      const result = {
-        success: true,
-        filesModified: ['/home/user/file1.ts'],
-        totalEdits: 1,
-        errors: [],
-      };
-      const formatted = formatApplyResult(result);
-      expect(formatted).toContain('Applied 1 edit(s)');
-    });
-  });
-});

+ 0 - 389
src/tools/lsp/utils.ts

@@ -1,389 +0,0 @@
-// LSP Utilities - Essential formatters and helpers
-
-import * as fs from 'node:fs';
-import { dirname, extname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { log } from '../../utils/logger';
-import type { LSPClient } from './client';
-import { lspManager } from './client';
-import { findServerForExtension } from './config';
-import { SEVERITY_MAP, SYMBOL_KIND_MAP } from './constants';
-import type {
-  Diagnostic,
-  Location,
-  LocationLink,
-  ResolvedServer,
-  ServerLookupResult,
-  TextEdit,
-  WorkspaceEdit,
-} from './types';
-
-/**
- * Find the project root for a specific LSP server using its root function.
- * Mirrors OpenCode core's RootFunction approach.
- *
- * @param filePath - The file to find the root for
- * @param server - The LSP server config with root function
- * @returns The project root directory, or file's directory if no root function
- */
-export function findServerProjectRoot(
-  filePath: string,
-  server: ResolvedServer,
-): string {
-  // Use the server's root function if available, otherwise use file's directory
-  if (server.root) {
-    return server.root(filePath) ?? dirname(resolve(filePath));
-  }
-  return dirname(resolve(filePath));
-}
-
-/**
- * Legacy function for backward compatibility.
- * @deprecated Use findServerProjectRoot with server-specific patterns instead.
- */
-export function findWorkspaceRoot(filePath: string): string {
-  let dir = resolve(filePath);
-
-  try {
-    if (!fs.statSync(dir).isDirectory()) {
-      dir = dirname(dir);
-    }
-  } catch {
-    dir = dirname(dir);
-  }
-
-  const markers = [
-    '.git',
-    'package.json',
-    'pyproject.toml',
-    'Cargo.toml',
-    'go.mod',
-  ];
-
-  let prevDir = '';
-  while (dir !== prevDir) {
-    for (const marker of markers) {
-      if (fs.existsSync(join(dir, marker))) {
-        return dir;
-      }
-    }
-    prevDir = dir;
-    dir = dirname(dir);
-  }
-
-  return dirname(resolve(filePath));
-}
-
-export function uriToPath(uri: string): string {
-  return fileURLToPath(uri);
-}
-
-export function formatServerLookupError(
-  result: Exclude<ServerLookupResult, { status: 'found' }>,
-): string {
-  if (result.status === 'not_installed') {
-    return [
-      `LSP server '${result.server.id}' is NOT INSTALLED.`,
-      ``,
-      `Command not found: ${result.server.command[0]}`,
-      ``,
-      `To install: ${result.installHint}`,
-    ].join('\n');
-  }
-
-  return `No LSP server configured for extension: ${result.extension}`;
-}
-
-export async function withLspClient<T>(
-  filePath: string,
-  fn: (client: LSPClient) => Promise<T>,
-): Promise<T> {
-  const absPath = resolve(filePath);
-  const ext = extname(absPath);
-  const result = findServerForExtension(ext, absPath);
-
-  if (result.status !== 'found') {
-    log('[lsp] withLspClient: server not found', {
-      filePath: absPath,
-      extension: ext,
-    });
-    throw new Error(formatServerLookupError(result));
-  }
-
-  const server = result.server;
-  // Use server-specific root detection instead of generic workspace root
-  // Fall back to file's directory if no root patterns match
-  const root = findServerProjectRoot(absPath, server) ?? dirname(absPath);
-
-  log('[lsp] withLspClient: selected server', {
-    filePath: absPath,
-    extension: ext,
-    server: server.id,
-    command: server.command.join(' '),
-    root,
-  });
-
-  log('[lsp] withLspClient: acquiring client', {
-    filePath: absPath,
-    server: server.id,
-    root,
-  });
-
-  const client = await lspManager.getClient(root, server);
-
-  try {
-    const result = await fn(client);
-    log('[lsp] withLspClient: operation complete', { server: server.id });
-    return result;
-  } catch (e) {
-    if (e instanceof Error && e.message.includes('timeout')) {
-      const isInitializing = lspManager.isServerInitializing(root, server.id);
-      if (isInitializing) {
-        log('[lsp] withLspClient: timeout during init', {
-          server: server.id,
-        });
-        throw new Error(
-          `LSP server is still initializing. Please retry in a few seconds.`,
-        );
-      }
-    }
-    log('[lsp] withLspClient: operation failed', {
-      server: server.id,
-      error: String(e),
-    });
-    throw e;
-  } finally {
-    lspManager.releaseClient(root, server.id);
-  }
-}
-
-export function formatLocation(loc: Location | LocationLink): string {
-  if ('targetUri' in loc) {
-    const uri = uriToPath(loc.targetUri);
-    const line = loc.targetRange.start.line + 1;
-    const char = loc.targetRange.start.character;
-    return `${uri}:${line}:${char}`;
-  }
-
-  const uri = uriToPath(loc.uri);
-  const line = loc.range.start.line + 1;
-  const char = loc.range.start.character;
-  return `${uri}:${line}:${char}`;
-}
-
-export function formatSymbolKind(kind: number): string {
-  return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})`;
-}
-
-export function formatSeverity(severity: number | undefined): string {
-  if (!severity) return 'unknown';
-  return SEVERITY_MAP[severity] || `unknown(${severity})`;
-}
-
-export function formatDiagnostic(diag: Diagnostic): string {
-  const severity = formatSeverity(diag.severity);
-  const line = diag.range.start.line + 1;
-  const char = diag.range.start.character;
-  const source = diag.source ? `[${diag.source}]` : '';
-  const code = diag.code ? ` (${diag.code})` : '';
-  return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`;
-}
-
-export function filterDiagnosticsBySeverity(
-  diagnostics: Diagnostic[],
-  severityFilter?: 'error' | 'warning' | 'information' | 'hint' | 'all',
-): Diagnostic[] {
-  if (!severityFilter || severityFilter === 'all') {
-    return diagnostics;
-  }
-
-  const severityMap: Record<string, number> = {
-    error: 1,
-    warning: 2,
-    information: 3,
-    hint: 4,
-  };
-
-  const targetSeverity = severityMap[severityFilter];
-  return diagnostics.filter((d) => d.severity === targetSeverity);
-}
-
-// WorkspaceEdit application
-
-function applyTextEditsToFile(
-  filePath: string,
-  edits: TextEdit[],
-): { success: boolean; editCount: number; error?: string } {
-  try {
-    const content = fs.readFileSync(filePath, 'utf-8');
-    const lines = content.split('\n');
-
-    const sortedEdits = [...edits].sort((a, b) => {
-      if (b.range.start.line !== a.range.start.line) {
-        return b.range.start.line - a.range.start.line;
-      }
-      return b.range.start.character - a.range.start.character;
-    });
-
-    for (const edit of sortedEdits) {
-      const startLine = edit.range.start.line;
-      const startChar = edit.range.start.character;
-      const endLine = edit.range.end.line;
-      const endChar = edit.range.end.character;
-
-      if (startLine === endLine) {
-        const line = lines[startLine] || '';
-        lines[startLine] =
-          line.substring(0, startChar) + edit.newText + line.substring(endChar);
-      } else {
-        const firstLine = lines[startLine] || '';
-        const lastLine = lines[endLine] || '';
-        const newContent =
-          firstLine.substring(0, startChar) +
-          edit.newText +
-          lastLine.substring(endChar);
-        lines.splice(
-          startLine,
-          endLine - startLine + 1,
-          ...newContent.split('\n'),
-        );
-      }
-    }
-
-    fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
-    return { success: true, editCount: edits.length };
-  } catch (err) {
-    return {
-      success: false,
-      editCount: 0,
-      error: err instanceof Error ? err.message : String(err),
-    };
-  }
-}
-
-export interface ApplyResult {
-  success: boolean;
-  filesModified: string[];
-  totalEdits: number;
-  errors: string[];
-}
-
-export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
-  if (!edit) {
-    log('[lsp] applyWorkspaceEdit: no edit provided');
-    return {
-      success: false,
-      filesModified: [],
-      totalEdits: 0,
-      errors: ['No edit provided'],
-    };
-  }
-
-  const changeCount =
-    (edit.changes ? Object.keys(edit.changes).length : 0) +
-    (edit.documentChanges ? edit.documentChanges.length : 0);
-  log('[lsp] applyWorkspaceEdit: applying', { changeCount });
-
-  const result: ApplyResult = {
-    success: true,
-    filesModified: [],
-    totalEdits: 0,
-    errors: [],
-  };
-
-  if (edit.changes) {
-    for (const [uri, edits] of Object.entries(edit.changes)) {
-      const filePath = uriToPath(uri);
-      const applyResult = applyTextEditsToFile(filePath, edits);
-
-      if (applyResult.success) {
-        result.filesModified.push(filePath);
-        result.totalEdits += applyResult.editCount;
-      } else {
-        result.success = false;
-        result.errors.push(`${filePath}: ${applyResult.error}`);
-      }
-    }
-  }
-
-  if (edit.documentChanges) {
-    for (const change of edit.documentChanges) {
-      if ('kind' in change) {
-        if (change.kind === 'create') {
-          try {
-            const filePath = uriToPath(change.uri);
-            fs.writeFileSync(filePath, '', 'utf-8');
-            result.filesModified.push(filePath);
-          } catch (err) {
-            result.success = false;
-            result.errors.push(`Create ${change.uri}: ${err}`);
-          }
-        } else if (change.kind === 'rename') {
-          try {
-            const oldPath = uriToPath(change.oldUri);
-            const newPath = uriToPath(change.newUri);
-            const content = fs.readFileSync(oldPath, 'utf-8');
-            fs.writeFileSync(newPath, content, 'utf-8');
-            fs.unlinkSync(oldPath);
-            result.filesModified.push(newPath);
-          } catch (err) {
-            result.success = false;
-            result.errors.push(`Rename ${change.oldUri}: ${err}`);
-          }
-        } else if (change.kind === 'delete') {
-          try {
-            const filePath = uriToPath(change.uri);
-            fs.unlinkSync(filePath);
-            result.filesModified.push(filePath);
-          } catch (err) {
-            result.success = false;
-            result.errors.push(`Delete ${change.uri}: ${err}`);
-          }
-        }
-      } else {
-        const filePath = uriToPath(change.textDocument.uri);
-        const applyResult = applyTextEditsToFile(filePath, change.edits);
-
-        if (applyResult.success) {
-          result.filesModified.push(filePath);
-          result.totalEdits += applyResult.editCount;
-        } else {
-          result.success = false;
-          result.errors.push(`${filePath}: ${applyResult.error}`);
-        }
-      }
-    }
-  }
-
-  log('[lsp] applyWorkspaceEdit: complete', {
-    success: result.success,
-    filesModified: result.filesModified.length,
-    totalEdits: result.totalEdits,
-    errors: result.errors.length,
-  });
-
-  return result;
-}
-
-export function formatApplyResult(result: ApplyResult): string {
-  const lines: string[] = [];
-
-  if (result.success) {
-    lines.push(
-      `Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`,
-    );
-    for (const file of result.filesModified) {
-      lines.push(`  - ${file}`);
-    }
-  } else {
-    lines.push('Failed to apply some changes:');
-    for (const err of result.errors) {
-      lines.push(`  Error: ${err}`);
-    }
-    if (result.filesModified.length > 0) {
-      lines.push(`Successfully modified: ${result.filesModified.join(', ')}`);
-    }
-  }
-
-  return lines.join('\n');
-}

+ 1 - 1
src/tools/smartfetch/network.ts

@@ -469,7 +469,7 @@ function inferFilenameFromUrl(url: string) {
   try {
     const parsed = new URL(url);
     const last = parsed.pathname.split('/').filter(Boolean).pop();
-    if (!last || !last.includes('.')) return undefined;
+    if (!last?.includes('.')) return undefined;
     return decodeURIComponent(last);
   } catch {
     return undefined;