diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 887a431c7..e22298a84 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build lsp-tools-mcp submodule
+ run: npm ci && npm run build
+ working-directory: vendor/lsp-tools-mcp
- uses: oven-sh/setup-bun@v2
with:
@@ -58,6 +64,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build lsp-tools-mcp submodule
+ run: npm ci && npm run build
+ working-directory: vendor/lsp-tools-mcp
- uses: oven-sh/setup-bun@v2
with:
@@ -88,6 +100,11 @@ jobs:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
+ submodules: recursive
+
+ - name: Build lsp-tools-mcp submodule
+ run: npm ci && npm run build
+ working-directory: vendor/lsp-tools-mcp
- uses: oven-sh/setup-bun@v2
with:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..55d94e4c2
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "vendor/lsp-tools-mcp"]
+ path = vendor/lsp-tools-mcp
+ url = https://github.com/code-yeongyu/lsp-tools-mcp
diff --git a/AGENTS.md b/AGENTS.md
index 5469a7375..105661db2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,12 +19,12 @@ oh-my-opencode/
│ ├── create-hooks.ts # 5-tier hook composition
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # ~52 lifecycle hooks across 59 dirs (incl. 5 zauc-mocks + 1 shared + 1 `.sisyphus/` legacy state)
-│ ├── tools/ # 16 tool dirs; produces 20–39 tools (config-gated)
+│ ├── tools/ # 15 native tool dirs; LSP tools now served via built-in MCP
│ ├── features/ # 20 feature modules (incl. team-mode, background-agent, skill-mcp-manager, opencode-skill-loader, tmux-subagent, mcp-oauth, claude-code-plugin-loader, boulder-state, etc.)
│ ├── shared/ # 278 utility files (170 non-test); logger → oh-my-opencode.log in os.tmpdir() (50 MB cap, .1/.2 backups)
│ ├── config/ # Zod v4 schema system (30 schema files)
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth, refresh-model-capabilities, get-local-version, boulder
-│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
+│ ├── mcp/ # 4 built-in MCPs (3 remote + local stdio lsp)
│ ├── plugin/ # 10 OpenCode hook handlers + 5-tier hook composition
│ ├── plugin-handlers/ # 6-phase config loading pipeline
│ ├── openclaw/ # Bidirectional external integration (Discord/Telegram/HTTP/shell + reply listener daemon)
@@ -87,6 +87,8 @@ pluginModule.server(input, options)
**Always on (20):** `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_diagnostics`, `lsp_prepare_rename`, `lsp_rename`, `grep`, `glob`, `ast_grep_search`, `ast_grep_replace`, `session_list`, `session_read`, `session_search`, `session_info`, `background_output`, `background_cancel`, `call_omo_agent`, `task` (delegate), `skill`, `skill_mcp`.
+> Note: `lsp_*` tool names are now served by built-in MCP server `lsp` (via `vendor/lsp-tools-mcp`), preserving existing names through OpenCode MCP namespacing.
+
**Conditional:** `look_at` (+1, multimodal-looker not disabled), `interactive_bash` (+1, `tmux` binary available on PATH via `isInteractiveBashEnabled()`), `task_create`/`task_get`/`task_list`/`task_update` (+4, `experimental.task_system`), `edit` (+1, `hashline_edit`), `team_create`/`team_delete`/`team_shutdown_request`/`team_approve_shutdown`/`team_reject_shutdown`/`team_send_message`/`team_task_create`/`team_task_list`/`team_task_update`/`team_task_get`/`team_status`/`team_list` (+12, `team_mode.enabled`).
## TEAM MODE
@@ -146,7 +148,7 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
| Tier | Source | Loader | Mechanism |
|------|--------|--------|-----------|
-| 1. Built-in | `src/mcp/` | `createBuiltinMcps()` | 3 remote HTTP: websearch (Exa/Tavily), context7, grep_app |
+| 1. Built-in | `src/mcp/` | `createBuiltinMcps()` | 3 remote HTTP + 1 local stdio MCP (`lsp`) |
| 2. Claude Code | `.mcp.json` (project + user) | `claude-code-mcp-loader` | `${VAR}` env expansion (allowlist via `mcp_env_allowlist`) |
| 3. Skill-embedded | SKILL.md YAML frontmatter | `SkillMcpManager` (per-session) | stdio + HTTP, OAuth 2.0 + PKCE + DCR step-up |
@@ -156,9 +158,9 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
|------|----------|-------|
| Add new agent | `src/agents/` + `src/agents/builtin-agents/` | `createXXXAgent` factory + `mode: "primary" \| "subagent" \| "all"` |
| Add new hook | `src/hooks/{name}/` + register in `src/plugin/hooks/create-*-hooks.ts` | Pick the right tier (Session/ToolGuard/Transform/Continuation/Skill) |
-| Add new tool | `src/tools/{name}/` + register in `src/plugin/tool-registry.ts` | Factory `createXXXTool` (most) or direct `ToolDefinition` (LSP, interactive_bash) |
+| Add new tool | `src/tools/{name}/` + register in `src/plugin/tool-registry.ts` | Factory `createXXXTool` (most) or direct `ToolDefinition` (interactive_bash) |
| Add new feature module | `src/features/{name}/` | Standalone module wired into `plugin/` layer |
-| Add new MCP (tier 1) | `src/mcp/` + register in `createBuiltinMcps()` | Remote HTTP only |
+| Add new MCP (tier 1) | `src/mcp/` + register in `createBuiltinMcps()` | Remote HTTP or local stdio |
| Add new built-in skill | `src/features/builtin-skills/skills/{name}.ts` + register in `skills.ts` | Implement `BuiltinSkill` interface |
| Add new command | `src/features/builtin-commands/` | Templates in `templates/` |
| Add new CLI subcommand | `src/cli/cli-program.ts` | Commander.js subcommand |
diff --git a/bunfig.toml b/bunfig.toml
index 8cac6fdb2..4ad7df05a 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,3 +1,3 @@
[test]
preload = ["./test-setup.ts"]
-pathIgnorePatterns = ["web/**"]
+pathIgnorePatterns = ["web/**", "vendor/**"]
diff --git a/package.json b/package.json
index 07c82205a..b75562912 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"files": [
"dist",
"bin",
- "postinstall.mjs"
+ "postinstall.mjs",
+ "vendor/lsp-tools-mcp/dist"
],
"exports": {
".": {
@@ -23,6 +24,7 @@
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
+ "build:lsp-tools-mcp": "npm ci --prefix vendor/lsp-tools-mcp && npm run build --prefix vendor/lsp-tools-mcp",
"build:node-require-shim": "bun run script/patch-node-require-shim.ts",
"build:all": "bun run build && bun run build:binaries",
"build:binaries": "bun run script/build-binaries.ts",
@@ -31,7 +33,7 @@
"clean": "rm -rf dist",
"prepare": "bun run build",
"postinstall": "node postinstall.mjs",
- "prepublishOnly": "bun run clean && bun run build",
+ "prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
diff --git a/src/AGENTS.md b/src/AGENTS.md
index b6ccb158d..5f1631628 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -90,7 +90,7 @@ Total: 54 base, 61 with team-mode. Each tier produces an object whose values are
|--------|-------------|-----|---------|---------------|
| `agents/` | 104 | ~20k | 11 agent factories + dynamic prompt builder | yes (+ atlas, hephaestus, prometheus, sisyphus, sisyphus-junior, builtin-agents) |
| `hooks/` | 596 | ~78k | ~52 lifecycle hooks across 58 dirs | yes (+ atlas, anthropic-context-window-limit-recovery, auto-update-checker, claude-code-hooks, comment-checker, compaction-context-injector, keyword-detector, ralph-loop, rules-injector, runtime-fallback, session-recovery, todo-continuation-enforcer) |
-| `tools/` | 317 | ~45k | 16 tool dirs producing 20–39 tools | yes (+ ast-grep, background-task, call-omo-agent, delegate-task, hashline-edit, look-at, lsp, skill) |
+| `tools/` | 317 | ~45k | 14 native tool dirs (+1 shared utilities dir); LSP moved to built-in MCP | yes (+ ast-grep, background-task, call-omo-agent, delegate-task, hashline-edit, look-at, skill) |
| `features/` | 404 | ~71k | 20 feature modules (team-mode, background-agent, boulder-state, etc.) | yes (+ 11 sub-AGENTS.md including builtin-skills, team-mode, background-agent, claude-code-*) |
| `shared/` | 290 | ~33k | Cross-cutting utilities, barrel-exported | yes |
| `cli/` | 158 | ~18k | Commander.js CLI: install, run, doctor, mcp-oauth, boulder | yes (+ config-manager, doctor, run) |
@@ -99,7 +99,7 @@ Total: 54 base, 61 with team-mode. Each tier produces an object whose values are
| `plugin-handlers/` | 27 | ~6k | 6-phase config loading pipeline | yes |
| `openclaw/` | 26 | ~3k | Bidirectional Discord/Telegram/HTTP integration | yes |
| `__tests__/` | 22 | ~300 | Plugin-level integration tests + perf fixtures | — |
-| `mcp/` | 7 | ~200 | 3 built-in remote MCPs | yes |
+| `mcp/` | 8 | ~260 | 4 built-in MCPs (3 remote + local stdio lsp) | yes |
| `testing/` | 3 | ~225 | Test utilities | — |
## NOTES
diff --git a/src/cli/doctor/checks/tools-lsp.test.ts b/src/cli/doctor/checks/tools-lsp.test.ts
new file mode 100644
index 000000000..03715ce7f
--- /dev/null
+++ b/src/cli/doctor/checks/tools-lsp.test.ts
@@ -0,0 +1,80 @@
+///
+
+import { afterEach, describe, expect, it, mock } from "bun:test"
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { clearPluginConfigFileDetectionCache } from "../../../shared/jsonc-parser"
+
+const originalCwd = process.cwd()
+const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
+const temporaryDirectories: string[] = []
+
+function createTemporaryDirectory(prefix: string): string {
+ const directory = mkdtempSync(join(tmpdir(), prefix))
+ temporaryDirectories.push(directory)
+ return directory
+}
+
+afterEach(() => {
+ mock.restore()
+ clearPluginConfigFileDetectionCache()
+ process.chdir(originalCwd)
+
+ if (originalOpenCodeConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
+ }
+
+ for (const directory of temporaryDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
+
+describe("getInstalledLspServers", () => {
+ it("returns empty when lsp MCP is disabled via config", async () => {
+ // given
+ const userConfigDirectory = createTemporaryDirectory("omo-tools-lsp-user-")
+ const workspaceDirectory = createTemporaryDirectory("omo-tools-lsp-workspace-")
+ const projectConfigDirectory = join(workspaceDirectory, ".opencode")
+ mkdirSync(projectConfigDirectory, { recursive: true })
+ writeFileSync(
+ join(projectConfigDirectory, "oh-my-openagent.json"),
+ JSON.stringify({ disabled_mcps: ["lsp"] }),
+ "utf-8",
+ )
+ process.env.OPENCODE_CONFIG_DIR = userConfigDirectory
+ process.chdir(workspaceDirectory)
+ clearPluginConfigFileDetectionCache()
+
+ const { getInstalledLspServers } = await import(`./tools-lsp?t=${Date.now()}-disabled`)
+
+ // when
+ const servers = getInstalledLspServers()
+
+ // then
+ expect(servers).toEqual([])
+ })
+
+ it("returns bundled lsp server info when MCP is enabled and available", async () => {
+ // given
+ const userConfigDirectory = createTemporaryDirectory("omo-tools-lsp-user-")
+ const workspaceDirectory = createTemporaryDirectory("omo-tools-lsp-enabled-")
+ const lspCliDirectory = join(workspaceDirectory, "vendor", "lsp-tools-mcp", "dist")
+ mkdirSync(lspCliDirectory, { recursive: true })
+ writeFileSync(join(lspCliDirectory, "cli.js"), "#!/usr/bin/env node\n", "utf-8")
+ process.env.OPENCODE_CONFIG_DIR = userConfigDirectory
+ process.chdir(workspaceDirectory)
+ clearPluginConfigFileDetectionCache()
+
+ const { getInstalledLspServers } = await import(`./tools-lsp?t=${Date.now()}-enabled`)
+
+ // when
+ const servers = getInstalledLspServers()
+
+ // then
+ expect(servers).toEqual([{ id: "lsp-tools-mcp", extensions: ["*"] }])
+ })
+
+})
diff --git a/src/cli/doctor/checks/tools-lsp.ts b/src/cli/doctor/checks/tools-lsp.ts
index 945621367..3add74053 100644
--- a/src/cli/doctor/checks/tools-lsp.ts
+++ b/src/cli/doctor/checks/tools-lsp.ts
@@ -1,9 +1,51 @@
-import { getAllServers } from "../../../tools/lsp/config"
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import { createLspMcpConfig } from "../../../mcp/lsp"
+import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
+
+type OmoConfigForDoctor = {
+ disabled_mcps?: string[]
+}
+
+const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode")
+
+function readOmoConfig(configDirectory: string): OmoConfigForDoctor | null {
+ const detected = detectPluginConfigFile(configDirectory)
+ if (detected.format === "none") {
+ return null
+ }
+
+ try {
+ const content = readFileSync(detected.path, "utf-8")
+ return parseJsonc(content)
+ } catch {
+ return null
+ }
+}
+
+function isLspMcpDisabled(): boolean {
+ const userConfigDirectory = getOpenCodeConfigDir({ binary: "opencode" })
+ const userConfig = readOmoConfig(userConfigDirectory)
+ const projectConfig = readOmoConfig(PROJECT_CONFIG_DIR)
+
+ const disabledMcps = new Set([
+ ...(userConfig?.disabled_mcps ?? []),
+ ...(projectConfig?.disabled_mcps ?? []),
+ ])
+
+ return disabledMcps.has("lsp")
+}
export function getInstalledLspServers(): Array<{ id: string; extensions: string[] }> {
- const servers = getAllServers()
+ if (isLspMcpDisabled()) {
+ return []
+ }
- return servers
- .filter((s) => s.installed && !s.disabled)
- .map((s) => ({ id: s.id, extensions: s.extensions }))
+ const lspMcpConfig = createLspMcpConfig()
+
+ if (!lspMcpConfig) {
+ return []
+ }
+
+ return [{ id: "lsp-tools-mcp", extensions: ["*"] }]
}
diff --git a/src/features/claude-code-mcp-loader/AGENTS.md b/src/features/claude-code-mcp-loader/AGENTS.md
index 4ce3bcc4e..043db6169 100644
--- a/src/features/claude-code-mcp-loader/AGENTS.md
+++ b/src/features/claude-code-mcp-loader/AGENTS.md
@@ -61,7 +61,7 @@ loadMcpConfigs(ctx)
| Tier | Loader | Scope |
|------|--------|-------|
-| 1. Built-in | `src/mcp/` `createBuiltinMcps()` | Global, 3 remote HTTP MCPs |
+| 1. Built-in | `src/mcp/` `createBuiltinMcps()` | Global, 3 remote HTTP MCPs + local stdio `lsp` |
| 2. **Claude Code** | **This module** | **From `.mcp.json`, project + user** |
| 3. Skill-embedded | `src/features/skill-mcp-manager/` | Per-session, from SKILL.md YAML |
diff --git a/src/features/skill-mcp-manager/AGENTS.md b/src/features/skill-mcp-manager/AGENTS.md
index ddd11778a..9cddeb5f2 100644
--- a/src/features/skill-mcp-manager/AGENTS.md
+++ b/src/features/skill-mcp-manager/AGENTS.md
@@ -10,7 +10,7 @@
| Tier | Manager | Scope |
|------|---------|-------|
-| 1. Built-in | `createBuiltinMcps()` (src/mcp/) | Global, 3 remote HTTP |
+| 1. Built-in | `createBuiltinMcps()` (src/mcp/) | Global, 3 remote HTTP + 1 local stdio (`lsp`) |
| 2. Claude Code | `claude-code-mcp-loader` (src/features/) | From `.mcp.json` |
| 3. **Skill-embedded** | **`SkillMcpManager` (this module)** | **Per-session, from SKILL.md YAML** |
diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md
index b1c8f0c77..3683fbd6e 100644
--- a/src/mcp/AGENTS.md
+++ b/src/mcp/AGENTS.md
@@ -1,49 +1,31 @@
-# src/mcp/ — 3 Built-in Remote MCPs
+# src/mcp/ — 4 Built-in MCPs
-**Generated:** 2026-05-15
+**Generated:** 2026-05-18
## OVERVIEW
-Tier 1 of the three-tier MCP system. 3 remote HTTP MCPs created via `createBuiltinMcps(disabledMcps, config)`.
+Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps(disabledMcps, config)` and now include both remote MCPs and one local stdio MCP.
## BUILT-IN MCPs
-| Name | URL | Env Vars | Tools |
-|------|-----|----------|-------|
-| **websearch** | `mcp.exa.ai` (default) or `mcp.tavily.com` | `EXA_API_KEY` (optional), `TAVILY_API_KEY` (if tavily) | Web search |
-| **context7** | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
-| **grep_app** | `mcp.grep.app` | None | GitHub code search |
+| Name | Type | Endpoint / Command | Env Vars | Tools |
+|------|------|--------------------|----------|-------|
+| **websearch** | remote | `mcp.exa.ai` (default) or `mcp.tavily.com` | `EXA_API_KEY` (optional), `TAVILY_API_KEY` (if tavily) | Web search |
+| **context7** | remote | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
+| **grep_app** | remote | `mcp.grep.app` | None | GitHub code search |
+| **lsp** | local (stdio, node) | `node vendor/lsp-tools-mcp/dist/cli.js mcp` | `LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json` | `status`, diagnostics, goto definition, references, symbols, prepare_rename, rename |
-## REGISTRATION PATTERN
+## SUBMODULE ARCHITECTURE
-```typescript
-// Static export (context7, grep_app)
-export const context7 = {
- type: "remote" as const,
- url: "https://mcp.context7.com/mcp",
- enabled: true,
- oauth: false as const,
-}
-
-// Factory with config (websearch)
-export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig
-```
-
-## ENABLE/DISABLE
-
-```jsonc
-// Method 1: disabled_mcps array
-{ "disabled_mcps": ["websearch", "context7"] }
-
-// Method 2: enabled flag
-{ "mcp": { "websearch": { "enabled": false } } }
-```
+- The local `lsp` MCP is vendored as a git submodule at `vendor/lsp-tools-mcp/`.
+- Upstream project: https://github.com/code-yeongyu/lsp-tools-mcp
+- OMO resolves the CLI path dynamically in `src/mcp/lsp.ts` so both `src/` and `dist/` runtime layouts work.
## THREE-TIER SYSTEM
| Tier | Source | Mechanism |
|------|--------|-----------|
-| 1. Built-in | `src/mcp/` | 3 remote HTTP, created by `createBuiltinMcps()` |
+| 1. Built-in | `src/mcp/` | 3 remote HTTP MCPs + 1 local stdio MCP (`lsp`) via `createBuiltinMcps()` |
| 2. Claude Code | `.mcp.json` | `${VAR}` expansion via `claude-code-mcp-loader` |
| 3. Skill-embedded | SKILL.md YAML | Managed by `SkillMcpManager` (stdio + HTTP) |
@@ -51,8 +33,9 @@ export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig
| File | Purpose |
|------|---------|
-| `index.ts` | `createBuiltinMcps()` factory |
-| `types.ts` | `McpNameSchema`: "websearch" \| "context7" \| "grep_app" |
+| `index.ts` | `createBuiltinMcps()` registry for built-in MCPs |
+| `types.ts` | `McpNameSchema`: `"websearch" \| "context7" \| "grep_app" \| "lsp"` |
| `websearch.ts` | Exa/Tavily provider with config |
| `context7.ts` | Context7 with optional auth header |
| `grep-app.ts` | Grep.app (no auth) |
+| `lsp.ts` | Local stdio MCP config for vendored `lsp-tools-mcp` |
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index bc9da4d31..77431d3e5 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -1,6 +1,7 @@
import { createWebsearchConfig } from "./websearch"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
+import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
import type { OhMyOpenCodeConfig } from "../config/schema"
export { McpNameSchema, type McpName } from "./types"
@@ -13,8 +14,10 @@ type RemoteMcpConfig = {
oauth?: false
}
+type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig
+
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
- const mcps: Record = {}
+ const mcps: Record = {}
if (!disabledMcps.includes("websearch")) {
const websearchConfig = createWebsearchConfig(config?.websearch)
@@ -31,5 +34,12 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
mcps.grep_app = grep_app
}
+ if (!disabledMcps.includes("lsp")) {
+ const lspConfig = createLspMcpConfig()
+ if (lspConfig) {
+ mcps.lsp = lspConfig
+ }
+ }
+
return mcps
}
diff --git a/src/mcp/lsp.ts b/src/mcp/lsp.ts
new file mode 100644
index 000000000..f43e66106
--- /dev/null
+++ b/src/mcp/lsp.ts
@@ -0,0 +1,62 @@
+import { existsSync } from "node:fs"
+import { resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const SUBMODULE_REL = "vendor/lsp-tools-mcp"
+const CLI_REL = "dist/cli.js"
+const PROJECT_LSP_CONFIG = ".opencode/lsp.json"
+
+export type LocalMcpConfig = {
+ type: "local"
+ command: string[]
+ enabled: boolean
+ environment?: Record
+}
+
+function addCliPathCandidates(startDirectory: string, maxParentDepth: number, target: Set): void {
+ let currentDirectory = startDirectory
+
+ for (let depth = 0; depth <= maxParentDepth; depth += 1) {
+ target.add(resolve(currentDirectory, SUBMODULE_REL, CLI_REL))
+
+ const parentDirectory = resolve(currentDirectory, "..")
+ if (parentDirectory === currentDirectory) {
+ return
+ }
+
+ currentDirectory = parentDirectory
+ }
+}
+
+function resolveLspCliPathCandidates(): string[] {
+ const candidates = new Set()
+
+ try {
+ const currentFilePath = fileURLToPath(import.meta.url)
+ const currentDirectory = resolve(currentFilePath, "..")
+ addCliPathCandidates(currentDirectory, 6, candidates)
+ } catch {
+ // ignore and fall through to cwd-based candidates
+ }
+
+ addCliPathCandidates(process.cwd(), 4, candidates)
+
+ return [...candidates]
+}
+
+export function createLspMcpConfig(): LocalMcpConfig | null {
+ const cliPath = resolveLspCliPathCandidates().find((candidatePath) => existsSync(candidatePath))
+
+ if (!cliPath) {
+ return null
+ }
+
+ return {
+ type: "local",
+ command: ["node", cliPath, "mcp"],
+ enabled: true,
+ environment: {
+ LSP_TOOLS_MCP_PROJECT_CONFIG: PROJECT_LSP_CONFIG,
+ },
+ }
+}
diff --git a/src/mcp/types.ts b/src/mcp/types.ts
index b3a24b8a7..f5e7f59a8 100644
--- a/src/mcp/types.ts
+++ b/src/mcp/types.ts
@@ -1,6 +1,6 @@
import { z } from "zod"
-export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"])
+export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp"])
export type McpName = z.infer
diff --git a/src/mcp/zauc-mocks-mcp-index/index.test.ts b/src/mcp/zauc-mocks-mcp-index/index.test.ts
index f14215b56..7e152c12c 100644
--- a/src/mcp/zauc-mocks-mcp-index/index.test.ts
+++ b/src/mcp/zauc-mocks-mcp-index/index.test.ts
@@ -1,9 +1,16 @@
-import { describe, expect, test } from "bun:test"
-import { createBuiltinMcps } from "../index"
+import { afterEach, describe, expect, mock, test } from "bun:test"
+
+afterEach(() => {
+ mock.restore()
+})
describe("createBuiltinMcps", () => {
test("should return all MCPs when disabled_mcps is empty", () => {
// given
+ mock.module("../lsp", () => ({
+ createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
+ }))
+ const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps: string[] = []
// when
@@ -14,10 +21,15 @@ describe("createBuiltinMcps", () => {
expect(result.websearch).toBeDefined()
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
+ expect(result.lsp).toBeDefined()
})
test("should filter out disabled MCPs", () => {
// given
+ mock.module("../lsp", () => ({
+ createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
+ }))
+ const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps = ["websearch"]
// when
@@ -27,11 +39,16 @@ describe("createBuiltinMcps", () => {
expect(result.websearch).toBeUndefined()
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
+ expect(result.lsp).toBeDefined()
})
test("should return empty array when all MCPs are disabled", () => {
// given - disable all known MCPs
- const disabledMcps = ["websearch", "context7", "grep_app"]
+ mock.module("../lsp", () => ({
+ createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
+ }))
+ const { createBuiltinMcps } = require("../index") as typeof import("../index")
+ const disabledMcps = ["websearch", "context7", "grep_app", "lsp"]
// when
const result = createBuiltinMcps(disabledMcps)
@@ -41,6 +58,7 @@ describe("createBuiltinMcps", () => {
expect(remainingMcpNames).not.toContain("websearch")
expect(remainingMcpNames).not.toContain("context7")
expect(remainingMcpNames).not.toContain("grep_app")
+ expect(remainingMcpNames).not.toContain("lsp")
expect(remainingMcpNames).toEqual([])
})
})
diff --git a/src/plugin-dispose.test.ts b/src/plugin-dispose.test.ts
index d0dd0285b..955254c19 100644
--- a/src/plugin-dispose.test.ts
+++ b/src/plugin-dispose.test.ts
@@ -12,14 +12,10 @@ describe("createPluginDispose", () => {
const skillMcpManager = {
disconnectAll: async (): Promise => {},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
- lspManager,
disposeHooks: (): void => {},
})
@@ -38,14 +34,10 @@ describe("createPluginDispose", () => {
const skillMcpManager = {
disconnectAll: async (): Promise => {},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
- lspManager,
disposeHooks: (): void => {},
})
@@ -73,9 +65,6 @@ describe("createPluginDispose", () => {
const autoSlashCommand = {
dispose: (): void => {},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const claudeCodeHooksDisposeSpy = spyOn(claudeCodeHooks, "dispose")
const commentCheckerDisposeSpy = spyOn(commentChecker, "dispose")
const runtimeFallbackDisposeSpy = spyOn(runtimeFallback, "dispose")
@@ -88,7 +77,6 @@ describe("createPluginDispose", () => {
skillMcpManager: {
disconnectAll: async (): Promise => {},
},
- lspManager,
disposeHooks: (): void => {
disposeCreatedHooks({
claudeCodeHooks,
@@ -119,20 +107,15 @@ describe("createPluginDispose", () => {
const skillMcpManager = {
disconnectAll: async (): Promise => {},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const disposeHooks = {
run: (): void => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
- const stopAllSpy = spyOn(lspManager, "stopAll")
const disposeHooksSpy = spyOn(disposeHooks, "run")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
- lspManager,
disposeHooks: disposeHooks.run,
})
@@ -143,7 +126,6 @@ describe("createPluginDispose", () => {
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
- expect(stopAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksSpy).toHaveBeenCalledTimes(1)
})
@@ -157,15 +139,11 @@ describe("createPluginDispose", () => {
const skillMcpManager = {
disconnectAll: async (): Promise => {},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const disposeHooksCalls: number[] = []
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
- lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
@@ -189,15 +167,11 @@ describe("createPluginDispose", () => {
throw new Error("disconnectAll failed")
},
}
- const lspManager = {
- stopAll: async (): Promise => {},
- }
const disposeHooksCalls: number[] = []
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
- lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
@@ -210,28 +184,4 @@ describe("createPluginDispose", () => {
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
-
- test("#given active LSP clients #when dispose runs #then lsp manager is stopped", async () => {
- // given
- const lspManager = {
- stopAll: async (): Promise => {},
- }
- const stopAllSpy = spyOn(lspManager, "stopAll")
- const dispose = createPluginDispose({
- backgroundManager: {
- shutdown: async (): Promise => {},
- },
- skillMcpManager: {
- disconnectAll: async (): Promise => {},
- },
- lspManager,
- disposeHooks: (): void => {},
- })
-
- // when
- await dispose()
-
- // then
- expect(stopAllSpy).toHaveBeenCalledTimes(1)
- })
})
diff --git a/src/plugin-dispose.ts b/src/plugin-dispose.ts
index 998fd28eb..d7a2f2640 100644
--- a/src/plugin-dispose.ts
+++ b/src/plugin-dispose.ts
@@ -9,12 +9,9 @@ export function createPluginDispose(args: {
skillMcpManager: {
disconnectAll: () => Promise
}
- lspManager: {
- stopAll: () => Promise
- }
disposeHooks: () => void
}): PluginDispose {
- const { backgroundManager, skillMcpManager, lspManager, disposeHooks } = args
+ const { backgroundManager, skillMcpManager, disposeHooks } = args
let disposePromise: Promise | null = null
return async (): Promise => {
@@ -34,11 +31,6 @@ export function createPluginDispose(args: {
} catch (error) {
log("[plugin-dispose] skillMcpManager.disconnectAll() error:", error)
}
- try {
- await lspManager.stopAll()
- } catch (error) {
- log("[plugin-dispose] lspManager.stopAll() error:", error)
- }
try {
disposeHooks()
} catch (error) {
diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md
index ee8e5a474..31b975e82 100644
--- a/src/plugin/AGENTS.md
+++ b/src/plugin/AGENTS.md
@@ -60,7 +60,6 @@ const lookAt = isMultimodalLookerEnabled ? { look_at: createLookAt(ctx) } : {}
const interactiveBashTool = interactiveBashEnabled ? { interactive_bash } : {}
const allTools = {
- ...builtinTools, // 6 LSP
...createGrepTools(ctx),
...createGlobTools(ctx),
...createAstGrepTools(ctx),
@@ -74,6 +73,8 @@ const allTools = {
...taskToolsRecord, // +4 conditional
...hashlineToolsRecord, // +1 conditional
}
+
+// lsp_* tools are now supplied by built-in MCP server "lsp"
```
## KEY PATTERNS
diff --git a/src/plugin/event.ts b/src/plugin/event.ts
index d14936726..ed2f6f901 100644
--- a/src/plugin/event.ts
+++ b/src/plugin/event.ts
@@ -36,7 +36,6 @@ import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retr
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state";
import { clearSessionPromptParams } from "../shared/session-prompt-params-state";
import { deleteSessionTools } from "../shared/session-tools-store";
-import { lspManager } from "../tools";
import { dispatchOpenClawEvent } from "../openclaw/runtime-dispatch";
import { createTeamIdleWakeHint } from "../hooks/team-session-events/team-idle-wake-hint";
import { buildTeamIdleWakeHintClient } from "./build-team-idle-wake-hint-client";
@@ -713,7 +712,6 @@ export function createEventHandler(args: {
}
deleteSessionTools(sessionID);
await managers.skillMcpManager.disconnectSession(sessionID);
- await lspManager.cleanupTempDirectoryClients();
if (tmuxIntegrationEnabled) {
await managers.tmuxSessionManager.onSessionDeleted({
sessionID,
diff --git a/src/plugin/tool-execute-before.test.ts b/src/plugin/tool-execute-before.test.ts
index 516c97d48..7facf671d 100644
--- a/src/plugin/tool-execute-before.test.ts
+++ b/src/plugin/tool-execute-before.test.ts
@@ -1,7 +1,6 @@
const { afterEach, describe, expect, test } = require("bun:test")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
const { createToolRegistry } = require("./tool-registry")
-const { builtinTools } = require("../tools")
const { resetStorageClient } = require("../tools/session-manager/storage")
describe("createToolExecuteBeforeHandler", () => {
@@ -335,13 +334,16 @@ describe("createToolRegistry", () => {
describe("#given max_tools is lower than or equal to builtin tool count", () => {
describe("#when creating the tool registry", () => {
test("#then it trims to the exact configured cap", () => {
+ const baseline = createToolRegistry(createRegistryInput())
+ const baselineToolCount = Object.keys(baseline.filteredTools).length
+
const result = createToolRegistry(
createRegistryInput({
- experimental: { max_tools: Object.keys(builtinTools).length },
+ experimental: { max_tools: baselineToolCount },
}),
)
- expect(Object.keys(result.filteredTools)).toHaveLength(Object.keys(builtinTools).length)
+ expect(Object.keys(result.filteredTools)).toHaveLength(baselineToolCount)
})
})
})
diff --git a/src/plugin/tool-registry.team-mode.test.ts b/src/plugin/tool-registry.team-mode.test.ts
index 787c7012a..bdf995497 100644
--- a/src/plugin/tool-registry.team-mode.test.ts
+++ b/src/plugin/tool-registry.team-mode.test.ts
@@ -157,7 +157,6 @@ describe("team-mode tool registry wiring", () => {
},
availableCategories: [],
toolFactories: {
- builtinTools: { bash: fakeTool, read: fakeTool },
createBackgroundTools: mock(() => ({})),
createCallOmoAgent: mock(() => fakeTool),
createLookAt: mock(() => fakeTool),
diff --git a/src/plugin/tool-registry.test.ts b/src/plugin/tool-registry.test.ts
index 7fc2f1723..03a8ec5a9 100644
--- a/src/plugin/tool-registry.test.ts
+++ b/src/plugin/tool-registry.test.ts
@@ -46,7 +46,6 @@ const TEAM_TOOL_NAMES = [
const { createToolRegistry, trimToolsToCap } = await import("./tool-registry")
const toolFactories: NonNullable[0]["toolFactories"]> = {
- builtinTools: { bash: fakeTool, read: fakeTool },
createBackgroundTools: mock(() => ({})),
createCallOmoAgent: mock(() => fakeTool),
createLookAt: mock(() => fakeTool),
diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts
index a95bc9ab9..2b249acc6 100644
--- a/src/plugin/tool-registry.ts
+++ b/src/plugin/tool-registry.ts
@@ -25,7 +25,6 @@ import * as openclawRuntimeDispatch from "../openclaw/runtime-dispatch"
import type { PluginContext, ToolsRecord } from "./types"
import {
- builtinTools,
createBackgroundTools,
createCallOmoAgent,
createLookAt,
@@ -53,7 +52,6 @@ import type { SkillContext } from "./skill-context"
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
type ToolRegistryFactories = {
- builtinTools: typeof builtinTools
createBackgroundTools: typeof createBackgroundTools
createCallOmoAgent: typeof createCallOmoAgent
createLookAt: typeof createLookAt
@@ -86,7 +84,6 @@ type ToolRegistryFactories = {
}
const defaultToolRegistryFactories: ToolRegistryFactories = {
- builtinTools,
createBackgroundTools,
createCallOmoAgent,
createLookAt,
@@ -339,7 +336,6 @@ export function createToolRegistry(args: {
: {}
const allTools: Record = {
- ...factories.builtinTools,
...factories.createGrepTools(ctx),
...factories.createGlobTools(ctx),
...factories.createAstGrepTools(ctx),
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index 480ba1a3b..d06842422 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -1,25 +1,26 @@
-# src/tools/ — 20–39 Tools Across 16 Directories
+# src/tools/ — 14–33 Native Tools Across 14 Tool Directories (+ shared utilities)
**Generated:** 2026-05-15
## OVERVIEW
-Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) in `src/plugin/`. Two patterns: factory functions (`createXXXTool`) for most tools, direct `ToolDefinition` exports for the 6 LSP tools and `interactive_bash`. The total exposed count varies between 20 (minimum) and 39 (with all flags on) based on config gates listed below.
+Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) in `src/plugin/`. Native tools are factory-based (`createXXXTool`) except `interactive_bash` (`ToolDefinition`). LSP tools are no longer native `src/tools/` implementations; they are served by Tier-1 built-in MCP `lsp` and keep the same exposed names (`lsp_diagnostics`, `lsp_goto_definition`, etc.).
## TOOL CATALOG
-### Always On (20)
+### Always On (14 native tools)
| Group | Tools |
|-------|-------|
-| **LSP** (6) | `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_diagnostics`, `lsp_prepare_rename`, `lsp_rename` |
| **Search** (4) | `grep`, `glob`, `ast_grep_search`, `ast_grep_replace` |
| **Sessions** (4) | `session_list`, `session_read`, `session_search`, `session_info` |
| **Background tasks** (2) | `background_output`, `background_cancel` |
| **Delegation** (2) | `task` (delegate, full skill+category support), `call_omo_agent` (named agent only: explore, librarian) |
| **Skills/MCP** (2) | `skill` (load skill or invoke command), `skill_mcp` (call skill-embedded MCP tool/resource/prompt) |
-### Conditional (up to +19)
+> LSP tools are now provided by built-in MCP server `lsp` (Tier-1 stdio), backed by `vendor/lsp-tools-mcp/`. OpenCode-compatible aliases remain available (`lsp_status`, `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`).
+
+### Conditional (up to +19 native tools)
| Tool(s) | Gate | Source |
|---------|------|--------|
@@ -76,7 +77,6 @@ tools/
├── hashline-edit/ # edit — hash-anchored line edits with LINE#ID validation
├── interactive-bash/ # interactive_bash — tmux session control
├── look-at/ # look_at — image/PDF analysis
-├── lsp/ # 6 LSP tools (direct ToolDefinition)
├── session-manager/ # 4 session_* tools
├── skill/ # skill — load skill or run command
├── skill-mcp/ # skill_mcp — call skill-embedded MCP servers
diff --git a/src/tools/index.ts b/src/tools/index.ts
index fee18f604..af9ee0ce7 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,15 +1,3 @@
-import {
- lsp_goto_definition,
- lsp_find_references,
- lsp_symbols,
- lsp_diagnostics,
- lsp_prepare_rename,
- lsp_rename,
- lspManager,
-} from "./lsp"
-
-export { lspManager }
-
export { createAstGrepTools } from "./ast-grep"
export { createGrepTools } from "./grep"
export { createGlobTools } from "./glob"
@@ -54,12 +42,3 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
background_cancel: createBackgroundCancel(manager, cancelClient),
}
}
-
-export const builtinTools: Record = {
- lsp_goto_definition,
- lsp_find_references,
- lsp_symbols,
- lsp_diagnostics,
- lsp_prepare_rename,
- lsp_rename,
-}
diff --git a/src/tools/lsp/AGENTS.md b/src/tools/lsp/AGENTS.md
deleted file mode 100644
index c023affb9..000000000
--- a/src/tools/lsp/AGENTS.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# src/tools/lsp/ — LSP Tool Implementations
-
-**Generated:** 2026-05-15
-
-## OVERVIEW
-
-33 files. Full LSP (Language Server Protocol) client stack exposed as 6 tools. Custom implementation that manages server processes, opens files, and forwards requests — does NOT delegate to OpenCode's built-in LSP.
-
-## TOOL EXPOSURE
-
-| Tool | File | What It Does |
-|------|------|--------------|
-| `lsp_goto_definition` | `goto-definition-tool.ts` | Jump to symbol definition |
-| `lsp_find_references` | `find-references-tool.ts` | All usages of a symbol |
-| `lsp_symbols` | `symbols-tool.ts` | Document outline or workspace symbol search |
-| `lsp_diagnostics` | `diagnostics-tool.ts` | Errors/warnings from language server |
-| `lsp_prepare_rename` | `rename-tools.ts` | Validate rename before applying |
-| `lsp_rename` | `rename-tools.ts` | Apply safe rename across workspace |
-
-All 6 are direct `ToolDefinition` objects (not factory functions) — registered directly in `tool-registry.ts`.
-
-## ARCHITECTURE
-
-```
-tools.ts (6 ToolDefinition exports)
- ↓ uses
-LspClientWrapper (lsp-client-wrapper.ts)
- ↓ wraps
-LSPClient (lsp-client.ts) extends LSPClientConnection (lsp-client-connection.ts)
- ↓ communicates via
-LSPClientTransport (lsp-client-transport.ts)
- ↓ talks to
-LSPProcess (lsp-process.ts) — spawns server binary
-```
-
-## KEY FILES
-
-| File | Purpose |
-|------|---------|
-| `lsp-client-wrapper.ts` | High-level entry: resolves server, opens file, runs request |
-| `lsp-client.ts` | `LSPClient` — file tracking, document sync (`didOpen`/`didChange`) |
-| `lsp-client-connection.ts` | JSON-RPC request/response/notification layer |
-| `lsp-client-transport.ts` | stdin/stdout byte-stream framing |
-| `lsp-process.ts` | Spawn + cleanup of LSP server process |
-| `lsp-manager-process-cleanup.ts` | Reap orphan LSP processes on exit |
-| `lsp-manager-temp-directory-cleanup.ts` | Clean temp dirs used by some servers |
-| `server-definitions.ts` | 40+ builtin servers synced from OpenCode's `server.ts` |
-| `server-config-loader.ts` | Load custom server config from `.opencode/lsp.json` |
-| `server-resolution.ts` | Resolve which server handles a file extension |
-| `server-installation.ts` | Detect missing binaries, surface install hints |
-| `language-mappings.ts` | Extension → language ID mapping |
-| `lsp-formatters.ts` | Format LSP responses into human-readable strings |
-| `workspace-edit.ts` | Apply `WorkspaceEdit` results to disk (for rename) |
-| `types.ts` | `LSPServerConfig`, `Position`, `Range`, `Location`, `Diagnostic` etc. |
-
-## SERVER RESOLUTION
-
-```
-file.ts → extension (.ts) → language-mappings → server ID (typescript)
- → server-resolution: check user config (.opencode/lsp.json) → fall back to server-definitions.ts
- → server-installation: verify binary exists (warn with install hint if not)
- → LSPProcess.spawn(command[])
-```
-
-## NOTES
-
-- File must be opened via `didOpen` before any LSP request — `LSPClient.openFile()` handles this
-- 1s delay after `didOpen` for server initialization before sending requests
-- `lsp_servers` tool was removed — duplicates OpenCode's built-in `LspServers` tool
-- Synced with OpenCode's `server.ts` — when adding servers, check upstream first
diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts
deleted file mode 100644
index d7d4f7c73..000000000
--- a/src/tools/lsp/client.test.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
-import { join } from "node:path"
-import { tmpdir } from "node:os"
-
-import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test"
-
-mock.module("vscode-jsonrpc/node", () => ({
- createMessageConnection: () => {
- throw new Error("not used in unit test")
- },
- StreamMessageReader: function StreamMessageReader() {},
- StreamMessageWriter: function StreamMessageWriter() {},
-}))
-
-afterAll(() => { mock.restore() })
-
-import { LSPClient, lspManager, validateCwd } from "./client"
-import type { ResolvedServer } from "./types"
-import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
-
-describe("LSPClient", () => {
- beforeEach(async () => {
- await lspManager.stopAll()
- })
-
- afterEach(async () => {
- await lspManager.stopAll()
- })
-
- describe("openFile", () => {
- it("sends didChange when a previously opened file changes on disk", async () => {
- // #given
- const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-"))
- const filePath = join(dir, "test.ts")
- writeFileSync(filePath, "const a = 1\n")
-
- const originalSetTimeout = globalThis.setTimeout
- globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
- fn()
- return unsafeTestValue>(0)
- }) as typeof setTimeout
-
- const server: ResolvedServer = {
- id: "typescript",
- command: ["typescript-language-server", "--stdio"],
- extensions: [".ts"],
- priority: 0,
- }
-
- const client = new LSPClient(dir, server)
-
- // Stub protocol output: we only want to assert notifications.
- const sendNotificationSpy = spyOn(
- unsafeTestValue<{ sendNotification: (m: string, p?: unknown) => void }>(client),
- "sendNotification"
- )
-
- try {
- // #when
- await client.openFile(filePath)
- writeFileSync(filePath, "const a = 2\n")
- await client.openFile(filePath)
-
- // #then
- const methods = sendNotificationSpy.mock.calls.map((c) => c[0])
- expect(methods).toContain("textDocument/didOpen")
- expect(methods).toContain("textDocument/didChange")
- } finally {
- globalThis.setTimeout = originalSetTimeout
- rmSync(dir, { recursive: true, force: true })
- }
- })
- })
-
- describe("LSPServerManager", () => {
- it("recreates client after init failure instead of staying permanently blocked", async () => {
- //#given
- const dir = mkdtempSync(join(tmpdir(), "lsp-manager-test-"))
-
- const server: ResolvedServer = {
- id: "typescript",
- command: ["typescript-language-server", "--stdio"],
- extensions: [".ts"],
- priority: 0,
- }
-
- const startSpy = spyOn(LSPClient.prototype, "start")
- const initializeSpy = spyOn(LSPClient.prototype, "initialize")
- const isAliveSpy = spyOn(LSPClient.prototype, "isAlive")
- const stopSpy = spyOn(LSPClient.prototype, "stop")
-
- startSpy.mockImplementationOnce(async () => {
- throw new Error("boom")
- })
- startSpy.mockImplementation(async () => {})
- initializeSpy.mockImplementation(async () => {})
- isAliveSpy.mockImplementation(() => true)
- stopSpy.mockImplementation(async () => {})
-
- try {
- //#when
- await expect(lspManager.getClient(dir, server)).rejects.toThrow("boom")
-
- const client = await lspManager.getClient(dir, server)
-
- //#then
- expect(client).toBeInstanceOf(LSPClient)
- expect(startSpy).toHaveBeenCalledTimes(2)
- expect(stopSpy).toHaveBeenCalled()
- } finally {
- startSpy.mockRestore()
- initializeSpy.mockRestore()
- isAliveSpy.mockRestore()
- stopSpy.mockRestore()
- rmSync(dir, { recursive: true, force: true })
- }
- })
-
- it("resets stale initializing entry so a hung init does not permanently block future clients", async () => {
- //#given
- const dir = mkdtempSync(join(tmpdir(), "lsp-manager-stale-test-"))
-
- const server: ResolvedServer = {
- id: "typescript",
- command: ["typescript-language-server", "--stdio"],
- extensions: [".ts"],
- priority: 0,
- }
-
- const dateNowSpy = spyOn(Date, "now")
-
- const startSpy = spyOn(LSPClient.prototype, "start")
- const initializeSpy = spyOn(LSPClient.prototype, "initialize")
- const isAliveSpy = spyOn(LSPClient.prototype, "isAlive")
- const stopSpy = spyOn(LSPClient.prototype, "stop")
-
- // First client init hangs forever.
- const never = new Promise(() => {})
- startSpy.mockImplementationOnce(async () => {
- await never
- })
-
- // Second attempt should be allowed after stale reset.
- startSpy.mockImplementationOnce(async () => {})
- startSpy.mockImplementation(async () => {})
- initializeSpy.mockImplementation(async () => {})
- isAliveSpy.mockImplementation(() => true)
- stopSpy.mockImplementation(async () => {})
-
- try {
- //#when
- dateNowSpy.mockReturnValueOnce(0)
- lspManager.warmupClient(dir, server)
-
- dateNowSpy.mockReturnValueOnce(60_000)
-
- const client = await Promise.race([
- lspManager.getClient(dir, server),
- new Promise((_, reject) => setTimeout(() => reject(new Error("test-timeout")), 50)),
- ])
-
- //#then
- expect(client).toBeInstanceOf(LSPClient)
- expect(startSpy).toHaveBeenCalledTimes(2)
- expect(stopSpy).toHaveBeenCalled()
- } finally {
- dateNowSpy.mockRestore()
- startSpy.mockRestore()
- initializeSpy.mockRestore()
- isAliveSpy.mockRestore()
- stopSpy.mockRestore()
- rmSync(dir, { recursive: true, force: true })
- }
- })
- })
-
- describe("validateCwd", () => {
- it("returns valid for existing directory", () => {
- // #given
- const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-test-"))
-
- try {
- // #when
- const result = validateCwd(dir)
-
- // #then
- expect(result.valid).toBe(true)
- expect(result.error).toBeUndefined()
- } finally {
- rmSync(dir, { recursive: true, force: true })
- }
- })
-
- it("returns invalid for non-existent directory", () => {
- // #given
- const nonExistentDir = join(tmpdir(), "lsp-cwd-nonexistent-" + Date.now())
-
- // #when
- const result = validateCwd(nonExistentDir)
-
- // #then
- expect(result.valid).toBe(false)
- expect(result.error).toContain("Working directory does not exist")
- })
-
- it("returns invalid when path is a file", () => {
- // #given
- const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-file-test-"))
- const filePath = join(dir, "not-a-dir.txt")
- writeFileSync(filePath, "test content")
-
- try {
- // #when
- const result = validateCwd(filePath)
-
- // #then
- expect(result.valid).toBe(false)
- expect(result.error).toContain("Path is not a directory")
- } finally {
- rmSync(dir, { recursive: true, force: true })
- }
- })
- })
-
- describe("start", () => {
- it("throws error when working directory does not exist", async () => {
- // #given
- const nonExistentDir = join(tmpdir(), "lsp-test-nonexistent-" + Date.now())
- const server: ResolvedServer = {
- id: "typescript",
- command: ["typescript-language-server", "--stdio"],
- extensions: [".ts"],
- priority: 0,
- }
- const client = new LSPClient(nonExistentDir, server)
-
- // #when / #then
- await expect(client.start()).rejects.toThrow("Working directory does not exist")
- })
-
- it("throws error when path is a file instead of directory", async () => {
- // #given
- const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-"))
- const filePath = join(dir, "not-a-dir.txt")
- writeFileSync(filePath, "test content")
-
- const server: ResolvedServer = {
- id: "typescript",
- command: ["typescript-language-server", "--stdio"],
- extensions: [".ts"],
- priority: 0,
- }
- const client = new LSPClient(filePath, server)
-
- try {
- // #when / #then
- await expect(client.start()).rejects.toThrow("Path is not a directory")
- } finally {
- rmSync(dir, { recursive: true, force: true })
- }
- })
- })
-})
diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts
deleted file mode 100644
index 5e4651f1b..000000000
--- a/src/tools/lsp/client.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { validateCwd } from "./lsp-process"
-export { lspManager } from "./lsp-server"
-export { LSPClient } from "./lsp-client"
diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts
deleted file mode 100644
index 59459cde8..000000000
--- a/src/tools/lsp/config.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { describe, test, expect, beforeEach, afterEach } from "bun:test"
-import { isServerInstalled } from "./config"
-import { mkdtempSync, rmSync, writeFileSync } from "fs"
-import { join } from "path"
-import { tmpdir } from "os"
-
-describe("isServerInstalled", () => {
- let tempDir: string
- let savedEnv: { [key: string]: string | undefined }
-
- beforeEach(() => {
- tempDir = mkdtempSync(join(tmpdir(), "lsp-config-test-"))
- savedEnv = {
- PATH: process.env.PATH,
- Path: process.env.Path,
- PATHEXT: process.env.PATHEXT,
- }
- })
-
- afterEach(() => {
- try {
- rmSync(tempDir, { recursive: true, force: true })
- } catch {
- }
-
- if (process.platform === "win32") {
- const pathVal = savedEnv.PATH ?? savedEnv.Path
- if (pathVal === undefined) {
- delete process.env.PATH
- delete process.env.Path
- } else {
- process.env.PATH = pathVal
- process.env.Path = pathVal
- }
- } else {
- if (savedEnv.PATH === undefined) {
- delete process.env.PATH
- } else {
- process.env.PATH = savedEnv.PATH
- }
-
- if (savedEnv.Path === undefined) {
- delete process.env.Path
- } else {
- process.env.Path = savedEnv.Path
- }
- }
-
- const pathextVal = savedEnv.PATHEXT
- if (pathextVal === undefined) {
- delete process.env.PATHEXT
- } else {
- process.env.PATHEXT = pathextVal
- }
- })
-
- test("detects executable in PATH", () => {
- const binName = "test-lsp-server"
- const ext = process.platform === "win32" ? ".cmd" : ""
- const binPath = join(tempDir, binName + ext)
-
- writeFileSync(binPath, "echo hello")
-
- const pathSep = process.platform === "win32" ? ";" : ":"
- process.env.PATH = `${tempDir}${pathSep}${process.env.PATH || ""}`
-
- expect(isServerInstalled([binName])).toBe(true)
- })
-
- test("returns false for missing executable", () => {
- expect(isServerInstalled(["non-existent-server"])).toBe(false)
- })
-
- if (process.platform === "win32") {
- test("Windows: detects executable with Path env var", () => {
- const binName = "test-lsp-server-case"
- const binPath = join(tempDir, binName + ".cmd")
- writeFileSync(binPath, "echo hello")
-
- delete process.env.PATH
- process.env.Path = tempDir
-
- expect(isServerInstalled([binName])).toBe(true)
- })
-
- test("Windows: respects PATHEXT", () => {
- const binName = "test-lsp-server-custom"
- const binPath = join(tempDir, binName + ".COM")
- writeFileSync(binPath, "echo hello")
-
- process.env.PATH = tempDir
- process.env.PATHEXT = ".COM;.EXE"
-
- expect(isServerInstalled([binName])).toBe(true)
- })
-
- test("Windows: ensures default extensions are checked even if PATHEXT is missing", () => {
- const binName = "test-lsp-server-default"
- const binPath = join(tempDir, binName + ".bat")
- writeFileSync(binPath, "echo hello")
-
- process.env.PATH = tempDir
- delete process.env.PATHEXT
-
- expect(isServerInstalled([binName])).toBe(true)
- })
-
- test("Windows: ensures default extensions are checked even if PATHEXT does not include them", () => {
- const binName = "test-lsp-server-ps1"
- const binPath = join(tempDir, binName + ".ps1")
- writeFileSync(binPath, "echo hello")
-
- process.env.PATH = tempDir
- process.env.PATHEXT = ".COM"
-
- expect(isServerInstalled([binName])).toBe(true)
- })
- } else {
- test("Non-Windows: does not use windows extensions", () => {
- const binName = "test-lsp-server-win"
- const binPath = join(tempDir, binName + ".cmd")
- writeFileSync(binPath, "echo hello")
-
- process.env.PATH = tempDir
-
- expect(isServerInstalled([binName])).toBe(false)
- })
- }
-})
diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts
deleted file mode 100644
index 2d36aa0f5..000000000
--- a/src/tools/lsp/config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution"
-export { getLanguageId } from "./language-config"
-export { isServerInstalled } from "./server-installation"
diff --git a/src/tools/lsp/constants.ts b/src/tools/lsp/constants.ts
deleted file mode 100644
index 758ff269c..000000000
--- a/src/tools/lsp/constants.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export const DEFAULT_MAX_REFERENCES = 200
-export const DEFAULT_MAX_SYMBOLS = 200
-export const DEFAULT_MAX_DIAGNOSTICS = 200
-export const DEFAULT_MAX_DIRECTORY_FILES = 50
-
-export { SYMBOL_KIND_MAP, SEVERITY_MAP, EXT_TO_LANG } from "./language-mappings"
-export { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions"
diff --git a/src/tools/lsp/diagnostics-tool.ts b/src/tools/lsp/diagnostics-tool.ts
deleted file mode 100644
index 0e0317beb..000000000
--- a/src/tools/lsp/diagnostics-tool.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { resolve } from "path"
-
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-
-import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
-import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
-import { inferExtensionFromDirectory } from "./infer-extension"
-import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
-import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper"
-import type { Diagnostic } from "./types"
-
-export const lsp_diagnostics: ToolDefinition = tool({
- description:
- 'Get errors, warnings, hints from language server BEFORE running build. Works for both single files and directories - file extension is auto-detected for directories.',
- args: {
- filePath: tool.schema
- .string()
- .describe("File or directory path to check diagnostics for"),
- severity: tool.schema
- .enum(["error", "warning", "information", "hint", "all"])
- .optional()
- .describe("Filter by severity level"),
- },
- execute: async (args, _context) => {
- try {
- if (!args.filePath) {
- throw new Error("'filePath' parameter is required.")
- }
- const absPath = resolve(args.filePath)
-
- if (isDirectoryPath(absPath)) {
- const extension = inferExtensionFromDirectory(absPath)
- if (!extension) {
- throw new Error(
- `No supported source files found in directory: ${absPath}`
- )
- }
- return await aggregateDiagnosticsForDirectory(absPath, extension, args.severity)
- }
-
- 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) {
- const output = "No diagnostics found"
- return output
- }
-
- 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}):`)
- }
- const output = lines.join("\n")
- return output
- } catch (e) {
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`
- throw new Error(output)
- }
- },
-})
diff --git a/src/tools/lsp/directory-diagnostics.test.ts b/src/tools/lsp/directory-diagnostics.test.ts
deleted file mode 100644
index 1c8f89133..000000000
--- a/src/tools/lsp/directory-diagnostics.test.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
-import { tmpdir } from "os"
-import { join } from "path"
-
-import * as configModule from "./config"
-import { lspManager } from "./lsp-server"
-import { isDirectoryPath } from "./lsp-client-wrapper"
-import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
-import type { Diagnostic } from "./types"
-
-const diagnosticsMock = mock(async (_filePath: string) => ({ items: [] as Diagnostic[] }))
-const getClientMock = mock(async () => ({ diagnostics: diagnosticsMock }))
-const releaseClientMock = mock(() => {})
-
-function createDiagnostic(message: string): Diagnostic {
- return {
- message,
- severity: 1,
- range: {
- start: { line: 0, character: 0 },
- end: { line: 0, character: 1 },
- },
- }
-}
-
-describe("directory diagnostics", () => {
- beforeEach(() => {
- diagnosticsMock.mockReset()
- diagnosticsMock.mockImplementation(async (_filePath: string) => ({ items: [] }))
- getClientMock.mockClear()
- releaseClientMock.mockClear()
-
- spyOn(configModule, "findServerForExtension").mockReturnValue({
- status: "found",
- server: {
- id: "test-server",
- command: ["test-server"],
- extensions: [".ts"],
- priority: 1,
- },
- })
- spyOn(lspManager, "getClient").mockImplementation(getClientMock as never)
- spyOn(lspManager, "releaseClient").mockImplementation(releaseClientMock)
- })
-
- afterEach(() => {
- mock.restore()
- })
-
- describe("isDirectoryPath", () => {
- it("returns true for existing directory", () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-"))
- try {
- expect(isDirectoryPath(tmp)).toBe(true)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
-
- it("returns false for existing file", () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-file-"))
- try {
- const file = join(tmp, "test.txt")
- writeFileSync(file, "content")
- expect(isDirectoryPath(file)).toBe(false)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
-
- it("returns false for non-existent path", () => {
- const nonExistent = join(tmpdir(), "omo-nonexistent-" + Date.now())
- expect(isDirectoryPath(nonExistent)).toBe(false)
- })
- })
-
- describe("aggregateDiagnosticsForDirectory", () => {
- it("throws error when extension does not start with dot", async () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-ext-"))
- try {
- await expect(aggregateDiagnosticsForDirectory(tmp, "ts")).rejects.toThrow(
- 'Extension must start with a dot (e.g., ".ts", not "ts")'
- )
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
-
- it("throws error when directory does not exist", async () => {
- const nonExistent = join(tmpdir(), "omo-nonexistent-dir-" + Date.now())
- await expect(aggregateDiagnosticsForDirectory(nonExistent, ".ts")).rejects.toThrow(
- "Directory does not exist"
- )
- })
-
- it("#given diagnostics from multiple files #when aggregating directory diagnostics #then each entry includes the source file path", async () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-files-"))
- try {
- const firstFile = join(tmp, "first.ts")
- const secondFile = join(tmp, "second.ts")
-
- writeFileSync(firstFile, "export const first = true\n")
- writeFileSync(secondFile, "export const second = true\n")
-
- diagnosticsMock.mockImplementation(async (filePath: string) => ({
- items: [createDiagnostic(`problem in ${filePath}`)],
- }))
-
- const result = await aggregateDiagnosticsForDirectory(tmp, ".ts")
-
- expect(result).toContain(`${firstFile}: error at 1:0: problem in ${firstFile}`)
- expect(result).toContain(`${secondFile}: error at 1:0: problem in ${secondFile}`)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
- })
-})
diff --git a/src/tools/lsp/directory-diagnostics.ts b/src/tools/lsp/directory-diagnostics.ts
deleted file mode 100644
index b3dd96106..000000000
--- a/src/tools/lsp/directory-diagnostics.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { existsSync, lstatSync, readdirSync, type Stats } from "fs"
-import { extname, join, resolve } from "path"
-
-import { findServerForExtension } from "./config"
-import { findWorkspaceRoot, formatServerLookupError } from "./lsp-client-wrapper"
-import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
-import { LSPClient } from "./lsp-client"
-import { lspManager } from "./lsp-server"
-import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants"
-import type { Diagnostic } from "./types"
-
-const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
-
-type FileDiagnostic = {
- filePath: string
- diagnostic: Diagnostic
-}
-
-function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] {
- const files: string[] = []
-
- function walk(currentDir: string): void {
- if (files.length >= maxFiles) return
-
- let entries: string[] = []
- try {
- entries = readdirSync(currentDir)
- } catch {
- return
- }
-
- for (const entry of entries) {
- if (files.length >= maxFiles) return
-
- const fullPath = join(currentDir, entry)
-
- let stat: Stats | undefined
- try {
- stat = lstatSync(fullPath)
- } catch {
- continue
- }
-
- if (!stat || stat.isSymbolicLink()) {
- continue
- }
-
- if (stat.isDirectory()) {
- if (!SKIP_DIRECTORIES.has(entry)) {
- walk(fullPath)
- }
- } else if (stat.isFile()) {
- if (extname(fullPath) === extension) {
- files.push(fullPath)
- }
- }
- }
- }
-
- walk(dir)
- return files
-}
-
-export async function aggregateDiagnosticsForDirectory(
- directory: string,
- extension: string,
- severity?: "error" | "warning" | "information" | "hint" | "all",
- maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES
-): Promise {
- if (!extension.startsWith(".")) {
- throw new Error(
- `Extension must start with a dot (e.g., ".ts", not "${extension}"). ` +
- `Use ".${extension}" instead.`
- )
- }
-
- const absDir = resolve(directory)
- if (!existsSync(absDir)) {
- throw new Error(`Directory does not exist: ${absDir}`)
- }
-
- const serverResult = findServerForExtension(extension)
- if (serverResult.status !== "found") {
- throw new Error(formatServerLookupError(serverResult))
- }
-
- const server = serverResult.server
- const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1)
- const wasCapped = allFiles.length > maxFiles
- const filesToProcess = allFiles.slice(0, maxFiles)
-
- if (filesToProcess.length === 0) {
- return [
- `Directory: ${absDir}`,
- `Extension: ${extension}`,
- `Files scanned: 0`,
- `No files found with extension "${extension}".`,
- ].join("\n")
- }
-
- const root = findWorkspaceRoot(absDir)
-
- const allDiagnostics: FileDiagnostic[] = []
- const fileErrors: { file: string; error: string }[] = []
-
- let client: LSPClient
- try {
- client = await lspManager.getClient(root, server)
-
- for (const file of filesToProcess) {
- try {
- const result = await client.diagnostics(file)
- const filtered = filterDiagnosticsBySeverity(result.items, severity)
- allDiagnostics.push(
- ...filtered.map((diagnostic) => ({
- filePath: file,
- diagnostic,
- }))
- )
- } catch (e) {
- fileErrors.push({
- file,
- error: e instanceof Error ? e.message : String(e),
- })
- }
- }
- } finally {
- lspManager.releaseClient(root, server.id)
- }
-
- const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS)
- const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS
-
- const lines: string[] = [
- `Directory: ${absDir}`,
- `Extension: ${extension}`,
- `Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`,
- `Files with errors: ${fileErrors.length}`,
- `Total diagnostics: ${allDiagnostics.length}`,
- ]
-
- if (fileErrors.length > 0) {
- lines.push("", "File processing errors:")
- for (const { file, error } of fileErrors) {
- lines.push(` ${file}: ${error}`)
- }
- }
-
- if (displayDiagnostics.length > 0) {
- lines.push("")
- for (const { filePath, diagnostic } of displayDiagnostics) {
- lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`)
- }
- if (wasDiagCapped) {
- lines.push(
- "",
- `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`
- )
- }
- }
-
- return lines.join("\n")
-}
diff --git a/src/tools/lsp/find-references-tool.ts b/src/tools/lsp/find-references-tool.ts
deleted file mode 100644
index 744e52e5d..000000000
--- a/src/tools/lsp/find-references-tool.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-
-import { DEFAULT_MAX_REFERENCES } from "./constants"
-import { formatLocation } from "./lsp-formatters"
-import { withLspClient } from "./lsp-client-wrapper"
-import type { Location } from "./types"
-
-export const lsp_find_references: ToolDefinition = tool({
- description: "Find ALL usages/references of a symbol across the entire workspace.",
- args: {
- filePath: tool.schema.string(),
- line: tool.schema.number().min(1).describe("1-based"),
- character: tool.schema.number().min(0).describe("0-based"),
- includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
- },
- execute: async (args, _context) => {
- 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) {
- const output = "No references found"
- return output
- }
-
- 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}):`)
- }
- const output = lines.join("\n")
- return output
- } catch (e) {
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`
- return output
- }
- },
-})
diff --git a/src/tools/lsp/goto-definition-tool.ts b/src/tools/lsp/goto-definition-tool.ts
deleted file mode 100644
index c72ebd4c4..000000000
--- a/src/tools/lsp/goto-definition-tool.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-
-import { formatLocation } from "./lsp-formatters"
-import { withLspClient } from "./lsp-client-wrapper"
-import type { Location, LocationLink } from "./types"
-
-export const lsp_goto_definition: ToolDefinition = tool({
- description: "Jump to symbol definition. Find WHERE something is defined.",
- args: {
- filePath: tool.schema.string(),
- line: tool.schema.number().min(1).describe("1-based"),
- character: tool.schema.number().min(0).describe("0-based"),
- },
- execute: async (args, _context) => {
- 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) {
- const output = "No definition found"
- return output
- }
-
- const locations = Array.isArray(result) ? result : [result]
- if (locations.length === 0) {
- const output = "No definition found"
- return output
- }
-
- const output = locations.map(formatLocation).join("\n")
- return output
- } catch (e) {
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`
- return output
- }
- },
-})
diff --git a/src/tools/lsp/index.ts b/src/tools/lsp/index.ts
deleted file mode 100644
index 924217654..000000000
--- a/src/tools/lsp/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from "./types"
-export * from "./constants"
-export * from "./config"
-export * from "./client"
-export * from "./lsp-client-wrapper"
-export * from "./lsp-formatters"
-export * from "./workspace-edit"
-// NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers
-export { lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools"
diff --git a/src/tools/lsp/infer-extension.test.ts b/src/tools/lsp/infer-extension.test.ts
deleted file mode 100644
index 6aea85838..000000000
--- a/src/tools/lsp/infer-extension.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from "bun:test"
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
-import { tmpdir } from "os"
-import { join } from "path"
-
-import { inferExtensionFromDirectory } from "./infer-extension"
-
-describe("inferExtensionFromDirectory", () => {
- let tmpDir: string
-
- beforeEach(() => {
- tmpDir = mkdtempSync(join(tmpdir(), "omo-infer-ext-"))
- })
-
- afterEach(() => {
- rmSync(tmpDir, { recursive: true, force: true })
- })
-
- describe("#given a directory with TypeScript files", () => {
- beforeEach(() => {
- writeFileSync(join(tmpDir, "index.ts"), "export const a = 1")
- writeFileSync(join(tmpDir, "utils.ts"), "export const b = 2")
- writeFileSync(join(tmpDir, "app.tsx"), "export const c = 3")
- })
-
- describe("#when inferring extension", () => {
- it("#then returns .ts as the most common extension", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBe(".ts")
- })
- })
- })
-
- describe("#given a directory with mixed file types where Python dominates", () => {
- beforeEach(() => {
- writeFileSync(join(tmpDir, "main.py"), "x = 1")
- writeFileSync(join(tmpDir, "utils.py"), "y = 2")
- writeFileSync(join(tmpDir, "helper.py"), "z = 3")
- writeFileSync(join(tmpDir, "config.ts"), "export default {}")
- })
-
- describe("#when inferring extension", () => {
- it("#then returns .py as the most common extension", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBe(".py")
- })
- })
- })
-
- describe("#given an empty directory", () => {
- describe("#when inferring extension", () => {
- it("#then returns null", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBeNull()
- })
- })
- })
-
- describe("#given a directory with only unsupported files", () => {
- beforeEach(() => {
- writeFileSync(join(tmpDir, "data.csv"), "a,b,c")
- writeFileSync(join(tmpDir, "image.png"), "fake")
- })
-
- describe("#when inferring extension", () => {
- it("#then returns null", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBeNull()
- })
- })
- })
-
- describe("#given a directory with nested subdirectories", () => {
- beforeEach(() => {
- writeFileSync(join(tmpDir, "root.go"), "package main")
- const sub = join(tmpDir, "pkg")
- mkdirSync(sub)
- writeFileSync(join(sub, "handler.go"), "package pkg")
- writeFileSync(join(sub, "model.go"), "package pkg")
- })
-
- describe("#when inferring extension", () => {
- it("#then counts files recursively", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBe(".go")
- })
- })
- })
-
- describe("#given a directory with node_modules", () => {
- beforeEach(() => {
- writeFileSync(join(tmpDir, "index.ts"), "export {}")
- const nm = join(tmpDir, "node_modules", "pkg")
- mkdirSync(nm, { recursive: true })
- writeFileSync(join(nm, "a.js"), "module.exports = {}")
- writeFileSync(join(nm, "b.js"), "module.exports = {}")
- writeFileSync(join(nm, "c.js"), "module.exports = {}")
- })
-
- describe("#when inferring extension", () => {
- it("#then skips node_modules and returns .ts", () => {
- const result = inferExtensionFromDirectory(tmpDir)
- expect(result).toBe(".ts")
- })
- })
- })
-})
diff --git a/src/tools/lsp/infer-extension.ts b/src/tools/lsp/infer-extension.ts
deleted file mode 100644
index 79259a782..000000000
--- a/src/tools/lsp/infer-extension.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { readdirSync, lstatSync } from "fs"
-import { extname, join } from "path"
-
-import { EXT_TO_LANG } from "./language-mappings"
-
-const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
-const MAX_SCAN_ENTRIES = 500
-
-export function inferExtensionFromDirectory(directory: string): string | null {
- const extensionCounts = new Map()
- let scanned = 0
-
- function walk(dir: string): void {
- if (scanned >= MAX_SCAN_ENTRIES) return
-
- let entries: string[]
- try {
- entries = readdirSync(dir)
- } catch {
- return
- }
-
- for (const entry of entries) {
- if (scanned >= MAX_SCAN_ENTRIES) return
-
- const fullPath = join(dir, entry)
-
- let stat: ReturnType | undefined
- try {
- stat = lstatSync(fullPath)
- } catch {
- continue
- }
-
- if (stat.isSymbolicLink()) continue
- scanned++
-
- if (stat.isDirectory()) {
- if (!SKIP_DIRECTORIES.has(entry)) {
- walk(fullPath)
- }
- } else if (stat.isFile()) {
- const ext = extname(fullPath)
- if (ext && ext in EXT_TO_LANG) {
- extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1)
- }
- }
- }
- }
-
- walk(directory)
-
- if (extensionCounts.size === 0) return null
-
- let maxExt = ""
- let maxCount = 0
- for (const [ext, count] of extensionCounts) {
- if (count > maxCount) {
- maxCount = count
- maxExt = ext
- }
- }
-
- return maxExt || null
-}
diff --git a/src/tools/lsp/language-config.ts b/src/tools/lsp/language-config.ts
deleted file mode 100644
index 75b84f8fb..000000000
--- a/src/tools/lsp/language-config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { EXT_TO_LANG } from "./constants"
-
-export function getLanguageId(ext: string): string {
- return EXT_TO_LANG[ext] || "plaintext"
-}
diff --git a/src/tools/lsp/language-mappings.ts b/src/tools/lsp/language-mappings.ts
deleted file mode 100644
index 136c68214..000000000
--- a/src/tools/lsp/language-mappings.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-export const SYMBOL_KIND_MAP: Record = {
- 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 = {
- 1: "error",
- 2: "warning",
- 3: "information",
- 4: "hint",
-}
-
-// Synced with OpenCode's language.ts
-// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts
-export const EXT_TO_LANG: Record = {
- ".abap": "abap",
- ".bat": "bat",
- ".bib": "bibtex",
- ".bibtex": "bibtex",
- ".clj": "clojure",
- ".cljs": "clojure",
- ".cljc": "clojure",
- ".edn": "clojure",
- ".coffee": "coffeescript",
- ".c": "c",
- ".cpp": "cpp",
- ".cxx": "cpp",
- ".cc": "cpp",
- ".c++": "cpp",
- ".cs": "csharp",
- ".css": "css",
- ".d": "d",
- ".pas": "pascal",
- ".pascal": "pascal",
- ".diff": "diff",
- ".patch": "diff",
- ".dart": "dart",
- ".dockerfile": "dockerfile",
- ".ex": "elixir",
- ".exs": "elixir",
- ".erl": "erlang",
- ".hrl": "erlang",
- ".fs": "fsharp",
- ".fsi": "fsharp",
- ".fsx": "fsharp",
- ".fsscript": "fsharp",
- ".gitcommit": "git-commit",
- ".gitrebase": "git-rebase",
- ".go": "go",
- ".groovy": "groovy",
- ".gleam": "gleam",
- ".hbs": "handlebars",
- ".handlebars": "handlebars",
- ".hs": "haskell",
- ".html": "html",
- ".htm": "html",
- ".ini": "ini",
- ".java": "java",
- ".js": "javascript",
- ".jsx": "javascriptreact",
- ".json": "json",
- ".jsonc": "jsonc",
- ".tex": "latex",
- ".latex": "latex",
- ".less": "less",
- ".lua": "lua",
- ".makefile": "makefile",
- makefile: "makefile",
- ".md": "markdown",
- ".markdown": "markdown",
- ".m": "objective-c",
- ".mm": "objective-cpp",
- ".pl": "perl",
- ".pm": "perl",
- ".pm6": "perl6",
- ".php": "php",
- ".ps1": "powershell",
- ".psm1": "powershell",
- ".pug": "jade",
- ".jade": "jade",
- ".py": "python",
- ".pyi": "python",
- ".r": "r",
- ".cshtml": "razor",
- ".razor": "razor",
- ".rb": "ruby",
- ".rake": "ruby",
- ".gemspec": "ruby",
- ".ru": "ruby",
- ".erb": "erb",
- ".html.erb": "erb",
- ".js.erb": "erb",
- ".css.erb": "erb",
- ".json.erb": "erb",
- ".rs": "rust",
- ".scss": "scss",
- ".sass": "sass",
- ".scala": "scala",
- ".shader": "shaderlab",
- ".sh": "shellscript",
- ".bash": "shellscript",
- ".zsh": "shellscript",
- ".ksh": "shellscript",
- ".sql": "sql",
- ".svelte": "svelte",
- ".swift": "swift",
- ".ts": "typescript",
- ".tsx": "typescriptreact",
- ".mts": "typescript",
- ".cts": "typescript",
- ".mtsx": "typescriptreact",
- ".ctsx": "typescriptreact",
- ".xml": "xml",
- ".xsl": "xsl",
- ".yaml": "yaml",
- ".yml": "yaml",
- ".mjs": "javascript",
- ".cjs": "javascript",
- ".vue": "vue",
- ".zig": "zig",
- ".zon": "zig",
- ".astro": "astro",
- ".ml": "ocaml",
- ".mli": "ocaml",
- ".tf": "terraform",
- ".tfvars": "terraform-vars",
- ".hcl": "hcl",
- ".nix": "nix",
- ".typ": "typst",
- ".typc": "typst",
- ".ets": "typescript",
- ".lhs": "haskell",
- ".kt": "kotlin",
- ".kts": "kotlin",
- ".prisma": "prisma",
- // Additional extensions not in OpenCode
- ".h": "c",
- ".hpp": "cpp",
- ".hh": "cpp",
- ".hxx": "cpp",
- ".h++": "cpp",
- ".objc": "objective-c",
- ".objcpp": "objective-cpp",
- ".fish": "fish",
- ".graphql": "graphql",
- ".gql": "graphql",
-}
diff --git a/src/tools/lsp/lsp-client-connection.ts b/src/tools/lsp/lsp-client-connection.ts
deleted file mode 100644
index e75a681c7..000000000
--- a/src/tools/lsp/lsp-client-connection.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { pathToFileURL } from "node:url"
-
-import { LSPClientTransport } from "./lsp-client-transport"
-
-export class LSPClientConnection extends LSPClientTransport {
- async initialize(): Promise {
- const rootUri = pathToFileURL(this.root).href
- await this.sendRequest("initialize", {
- processId: process.pid,
- rootUri,
- rootPath: this.root,
- workspaceFolders: [{ uri: rootUri, name: "workspace" }],
- capabilities: {
- textDocument: {
- hover: { contentFormat: ["markdown", "plaintext"] },
- definition: { linkSupport: true },
- references: {},
- documentSymbol: { hierarchicalDocumentSymbolSupport: true },
- publishDiagnostics: {},
- rename: {
- prepareSupport: true,
- prepareSupportDefaultBehavior: 1,
- honorsChangeAnnotations: true,
- },
- codeAction: {
- codeActionLiteralSupport: {
- codeActionKind: {
- valueSet: [
- "quickfix",
- "refactor",
- "refactor.extract",
- "refactor.inline",
- "refactor.rewrite",
- "source",
- "source.organizeImports",
- "source.fixAll",
- ],
- },
- },
- isPreferredSupport: true,
- disabledSupport: true,
- dataSupport: true,
- resolveSupport: {
- properties: ["edit", "command"],
- },
- },
- },
- workspace: {
- symbol: {},
- workspaceFolders: true,
- configuration: true,
- applyEdit: true,
- workspaceEdit: {
- documentChanges: true,
- },
- },
- },
- initializationOptions: this.server.initialization,
- })
- this.sendNotification("initialized")
- this.sendNotification("workspace/didChangeConfiguration", {
- settings: { json: { validate: { enable: true } } },
- })
- await new Promise((r) => setTimeout(r, 300))
- }
-}
diff --git a/src/tools/lsp/lsp-client-transport.ts b/src/tools/lsp/lsp-client-transport.ts
deleted file mode 100644
index 05d1d0f18..000000000
--- a/src/tools/lsp/lsp-client-transport.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import { Readable, Writable } from "node:stream"
-import { delimiter } from "path"
-import {
- createMessageConnection,
- StreamMessageReader,
- StreamMessageWriter,
- type MessageConnection,
-} from "vscode-jsonrpc/node"
-import type { Diagnostic, ResolvedServer } from "./types"
-import { spawnProcess, type UnifiedProcess } from "./lsp-process"
-import { getLspServerAdditionalPathBases } from "./server-path-bases"
-import { log } from "../../shared/logger"
-export class LSPClientTransport {
- protected proc: UnifiedProcess | null = null
- protected connection: MessageConnection | null = null
- protected readonly stderrBuffer: string[] = []
- protected processExited = false
- protected readonly diagnosticsStore = new Map()
- protected readonly REQUEST_TIMEOUT = 15000
-
- constructor(protected root: string, protected server: ResolvedServer) {}
- async start(): Promise {
- const env = {
- ...process.env,
- ...this.server.env,
- }
- const pathValue = process.platform === "win32" ? env.PATH ?? env.Path ?? "" : env.PATH ?? ""
- const spawnPath = [pathValue, ...getLspServerAdditionalPathBases(this.root)]
- .filter(Boolean)
- .join(delimiter)
- if (process.platform === "win32" && env.Path !== undefined) {
- env.Path = spawnPath
- }
- env.PATH = spawnPath
-
- this.proc = spawnProcess(this.server.command, {
- cwd: this.root,
- env,
- })
- if (!this.proc) {
- throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
- }
- this.startStderrReading()
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- if (this.proc.exitCode !== null) {
- const stderr = this.stderrBuffer.join("\n")
- throw new Error(`LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : ""))
- }
-
- const stdoutReader = this.proc.stdout.getReader()
- const nodeReadable = new Readable({
- async read() {
- try {
- const { done, value } = await stdoutReader.read()
- if (done || !value) {
- this.push(null)
- } else {
- this.push(Buffer.from(value))
- }
- } catch {
- this.push(null)
- }
- },
- })
-
- const stdin = this.proc.stdin
- const nodeWritable = new Writable({
- write(chunk, _encoding, callback) {
- try {
- stdin.write(chunk)
- callback()
- } catch (err) {
- callback(err as Error)
- }
- },
- })
-
- this.connection = createMessageConnection(new StreamMessageReader(nodeReadable), new StreamMessageWriter(nodeWritable))
-
- this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => {
- if (params.uri) {
- this.diagnosticsStore.set(params.uri, params.diagnostics ?? [])
- }
- })
-
- this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => {
- const items = params?.items ?? []
- return items.map((item) => {
- if (item.section === "json") return { validate: { enable: true } }
- return {}
- })
- })
-
- this.connection.onRequest("client/registerCapability", () => null)
- this.connection.onRequest("window/workDoneProgress/create", () => null)
-
- this.connection.onClose(() => {
- this.processExited = true
- })
-
- this.connection.onError((error) => {
- log("LSP connection error:", error)
- })
-
- this.connection.listen()
- }
-
- protected startStderrReading(): void {
- if (!this.proc) return
- const reader = this.proc.stderr.getReader()
- const read = async () => {
- const decoder = new TextDecoder()
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- const text = decoder.decode(value)
- this.stderrBuffer.push(text)
- if (this.stderrBuffer.length > 100) {
- this.stderrBuffer.shift()
- }
- }
- } catch {}
- }
- read()
- }
-
- protected sendRequest(method: string): Promise
- protected sendRequest(method: string, params: unknown): Promise
- protected async sendRequest(method: string, ...args: [] | [unknown]): Promise {
- if (!this.connection) throw new Error("LSP client not started")
-
- if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
- const stderr = this.stderrBuffer.slice(-10).join("\n")
- throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : ""))
- }
-
- let timeoutId: ReturnType | undefined
- const timeoutPromise = new Promise((_, reject) => {
- timeoutId = setTimeout(() => {
- const stderr = this.stderrBuffer.slice(-5).join("\n")
- reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
- }, this.REQUEST_TIMEOUT)
- })
- const clearRequestTimeout = (): void => {
- if (timeoutId !== undefined) {
- clearTimeout(timeoutId)
- }
- }
-
- const requestPromise = this.connection.sendRequest(method, ...args) as Promise
-
- try {
- const result = await Promise.race([requestPromise, timeoutPromise])
- clearRequestTimeout()
- return result
- } catch (error) {
- clearRequestTimeout()
- throw error
- }
- }
-
- protected sendNotification(method: string): void
- protected sendNotification(method: string, params: unknown): void
- protected sendNotification(method: string, ...args: [] | [unknown]): void {
- if (!this.connection) return
- if (this.processExited || (this.proc && this.proc.exitCode !== null)) return
- this.connection.sendNotification(method, ...args)
- }
-
- isAlive(): boolean {
- return this.proc !== null && !this.processExited && this.proc.exitCode === null
- }
-
- async stop(): Promise {
- if (this.connection) {
- try {
- this.sendNotification("shutdown", {})
- this.sendNotification("exit")
- } catch {}
- this.connection.dispose()
- this.connection = null
- }
- const proc = this.proc
- if (proc) {
- this.proc = null
- let exitedBeforeTimeout = false
- try {
- proc.kill()
- // Wait for exit with timeout to prevent indefinite hang
- let timeoutId: ReturnType | undefined
- const timeoutPromise = new Promise((resolve) => {
- timeoutId = setTimeout(resolve, 5000)
- })
- await Promise.race([
- proc.exited.then(() => {
- exitedBeforeTimeout = true
- }).finally(() => timeoutId && clearTimeout(timeoutId)),
- timeoutPromise,
- ])
- if (!exitedBeforeTimeout) {
- log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL")
- try {
- proc.kill("SIGKILL")
- // Wait briefly for SIGKILL to take effect
- await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 1000))])
- } catch {}
- }
- } catch {}
- }
- this.processExited = true
- this.diagnosticsStore.clear()
- }
-}
diff --git a/src/tools/lsp/lsp-client-wrapper.ts b/src/tools/lsp/lsp-client-wrapper.ts
deleted file mode 100644
index 6c3f82265..000000000
--- a/src/tools/lsp/lsp-client-wrapper.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { extname, resolve } from "path"
-import { fileURLToPath } from "node:url"
-import { existsSync, statSync } from "fs"
-
-import { LSPClient, lspManager } from "./client"
-import { findServerForExtension } from "./config"
-import type { ServerLookupResult } from "./types"
-import { CONFIG_BASENAME } from "../../shared/plugin-identity"
-
-export function isDirectoryPath(filePath: string): boolean {
- if (!existsSync(filePath)) {
- return false
- }
- return statSync(filePath).isDirectory()
-}
-
-export function uriToPath(uri: string): string {
- return fileURLToPath(uri)
-}
-
-export function findWorkspaceRoot(filePath: string): string {
- let dir = resolve(filePath)
-
- if (!existsSync(dir) || !isDirectoryPath(dir)) {
- dir = require("path").dirname(dir)
- }
-
- const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"]
-
- let prevDir = ""
- while (dir !== prevDir) {
- for (const marker of markers) {
- if (existsSync(require("path").join(dir, marker))) {
- return dir
- }
- }
- prevDir = dir
- dir = require("path").dirname(dir)
- }
-
- return require("path").dirname(resolve(filePath))
-}
-
-export function formatServerLookupError(result: Exclude): string {
- if (result.status === "not_installed") {
- const { server, installHint } = result
- return [
- `LSP server '${server.id}' is configured but NOT INSTALLED.`,
- ``,
- `Command not found: ${server.command[0]}`,
- ``,
- `To install:`,
- ` ${installHint}`,
- ``,
- `Supported extensions: ${server.extensions.join(", ")}`,
- ``,
- `After installation, the server will be available automatically.`,
- `Run 'LspServers' tool to verify installation status.`,
- ].join("\n")
- }
-
- return [
- `No LSP server configured for extension: ${result.extension}`,
- ``,
- `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
- ``,
- `To add a custom server, configure 'lsp' in ${CONFIG_BASENAME}.json:`,
- ` {`,
- ` "lsp": {`,
- ` "my-server": {`,
- ` "command": ["my-lsp", "--stdio"],`,
- ` "extensions": ["${result.extension}"]`,
- ` }`,
- ` }`,
- ` }`,
- ].join("\n")
-}
-
-export async function withLspClient(filePath: string, fn: (client: LSPClient) => Promise): Promise {
- const absPath = resolve(filePath)
-
- if (isDirectoryPath(absPath)) {
- throw new Error(
- `Directory paths are not supported by this LSP tool. ` +
- `Use lsp_diagnostics with the 'extension' parameter for directory diagnostics.`
- )
- }
-
- const ext = extname(absPath)
- const result = findServerForExtension(ext)
-
- if (result.status !== "found") {
- throw new Error(formatServerLookupError(result))
- }
-
- const server = result.server
- const root = findWorkspaceRoot(absPath)
- const client = await lspManager.getClient(root, server)
-
- try {
- return await fn(client)
- } catch (e) {
- if (e instanceof Error && e.message.includes("timeout")) {
- const isInitializing = lspManager.isServerInitializing(root, server.id)
- if (isInitializing) {
- throw new Error(
- `LSP server is still initializing. Please retry in a few seconds. ` +
- `Original error: ${e.message}`
- )
- }
- }
- throw e
- } finally {
- lspManager.releaseClient(root, server.id)
- }
-}
diff --git a/src/tools/lsp/lsp-client.ts b/src/tools/lsp/lsp-client.ts
deleted file mode 100644
index 4785909cc..000000000
--- a/src/tools/lsp/lsp-client.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { readFileSync } from "fs"
-import { extname, resolve } from "path"
-import { pathToFileURL } from "node:url"
-
-import { getLanguageId } from "./config"
-import { LSPClientConnection } from "./lsp-client-connection"
-import type { Diagnostic } from "./types"
-
-export class LSPClient extends LSPClientConnection {
- private openedFiles = new Set()
- private documentVersions = new Map()
- private lastSyncedText = new Map()
-
- async openFile(filePath: string): Promise {
- const absPath = resolve(filePath)
-
- const uri = pathToFileURL(absPath).href
- const text = readFileSync(absPath, "utf-8")
-
- if (!this.openedFiles.has(absPath)) {
- const ext = extname(absPath)
- const languageId = getLanguageId(ext)
- const version = 1
-
- this.sendNotification("textDocument/didOpen", {
- textDocument: {
- uri,
- languageId,
- version,
- text,
- },
- })
-
- this.openedFiles.add(absPath)
- this.documentVersions.set(uri, version)
- this.lastSyncedText.set(uri, text)
- await new Promise((r) => setTimeout(r, 1000))
- return
- }
-
- const prevText = this.lastSyncedText.get(uri)
- if (prevText === text) {
- return
- }
-
- const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1
- this.documentVersions.set(uri, nextVersion)
- this.lastSyncedText.set(uri, text)
-
- this.sendNotification("textDocument/didChange", {
- textDocument: { uri, version: nextVersion },
- contentChanges: [{ text }],
- })
-
- // Some servers update diagnostics only after save
- this.sendNotification("textDocument/didSave", {
- textDocument: { uri },
- text,
- })
- }
-
- async definition(filePath: string, line: number, character: number): Promise {
- const absPath = resolve(filePath)
- await this.openFile(absPath)
- return this.sendRequest("textDocument/definition", {
- textDocument: { uri: pathToFileURL(absPath).href },
- position: { line: line - 1, character },
- })
- }
-
- async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise {
- const absPath = resolve(filePath)
- await this.openFile(absPath)
- return this.sendRequest("textDocument/references", {
- textDocument: { uri: pathToFileURL(absPath).href },
- position: { line: line - 1, character },
- context: { includeDeclaration },
- })
- }
-
- async documentSymbols(filePath: string): Promise {
- const absPath = resolve(filePath)
- await this.openFile(absPath)
- return this.sendRequest("textDocument/documentSymbol", {
- textDocument: { uri: pathToFileURL(absPath).href },
- })
- }
-
- async workspaceSymbols(query: string): Promise {
- return this.sendRequest("workspace/symbol", { query })
- }
-
- async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
- const absPath = resolve(filePath)
- const uri = pathToFileURL(absPath).href
- await this.openFile(absPath)
- await new Promise((r) => setTimeout(r, 500))
-
- try {
- const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
- textDocument: { uri },
- })
- if (result && typeof result === "object" && "items" in result) {
- return result as { items: Diagnostic[] }
- }
- } catch {}
-
- return { items: this.diagnosticsStore.get(uri) ?? [] }
- }
-
- async prepareRename(filePath: string, line: number, character: number): Promise {
- const absPath = resolve(filePath)
- await this.openFile(absPath)
- return this.sendRequest("textDocument/prepareRename", {
- textDocument: { uri: pathToFileURL(absPath).href },
- position: { line: line - 1, character },
- })
- }
-
- async rename(filePath: string, line: number, character: number, newName: string): Promise {
- const absPath = resolve(filePath)
- await this.openFile(absPath)
- return this.sendRequest("textDocument/rename", {
- textDocument: { uri: pathToFileURL(absPath).href },
- position: { line: line - 1, character },
- newName,
- })
- }
-}
diff --git a/src/tools/lsp/lsp-formatters.ts b/src/tools/lsp/lsp-formatters.ts
deleted file mode 100644
index 0633d55f0..000000000
--- a/src/tools/lsp/lsp-formatters.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
-import { uriToPath } from "./lsp-client-wrapper"
-import type {
- Diagnostic,
- DocumentSymbol,
- Location,
- LocationLink,
- PrepareRenameDefaultBehavior,
- PrepareRenameResult,
- Range,
- SymbolInfo,
- TextEdit,
- WorkspaceEdit,
-} from "./types"
-import type { ApplyResult } from "./workspace-edit"
-
-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 formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string {
- const prefix = " ".repeat(indent)
- const kind = formatSymbolKind(symbol.kind)
- const line = symbol.range.start.line + 1
- let result = `${prefix}${symbol.name} (${kind}) - line ${line}`
-
- if (symbol.children && symbol.children.length > 0) {
- for (const child of symbol.children) {
- result += "\n" + formatDocumentSymbol(child, indent + 1)
- }
- }
-
- return result
-}
-
-export function formatSymbolInfo(symbol: SymbolInfo): string {
- const kind = formatSymbolKind(symbol.kind)
- const loc = formatLocation(symbol.location)
- const container = symbol.containerName ? ` (in ${symbol.containerName})` : ""
- return `${symbol.name} (${kind})${container} - ${loc}`
-}
-
-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 = {
- error: 1,
- warning: 2,
- information: 3,
- hint: 4,
- }
-
- const targetSeverity = severityMap[severityFilter]
- return diagnostics.filter((d) => d.severity === targetSeverity)
-}
-
-export function formatPrepareRenameResult(
- result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
-): string {
- if (!result) return "Cannot rename at this position"
-
- // Case 1: { defaultBehavior: boolean }
- if ("defaultBehavior" in result) {
- return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
- }
-
- // Case 2: { range: Range, placeholder?: string }
- if ("range" in result && result.range) {
- const startLine = result.range.start.line + 1
- const startChar = result.range.start.character
- const endLine = result.range.end.line + 1
- const endChar = result.range.end.character
- const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
- return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
- }
-
- // Case 3: Range directly (has start/end but no range property)
- if ("start" in result && "end" in result) {
- const startLine = result.start.line + 1
- const startChar = result.start.character
- const endLine = result.end.line + 1
- const endChar = result.end.character
- return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
- }
-
- return "Cannot rename at this position"
-}
-
-export function formatTextEdit(edit: TextEdit): string {
- const startLine = edit.range.start.line + 1
- const startChar = edit.range.start.character
- const endLine = edit.range.end.line + 1
- const endChar = edit.range.end.character
-
- const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}`
- const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText
-
- return ` ${rangeStr}: "${preview}"`
-}
-
-export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
- if (!edit) return "No changes"
-
- const lines: string[] = []
-
- if (edit.changes) {
- for (const [uri, edits] of Object.entries(edit.changes)) {
- const filePath = uriToPath(uri)
- lines.push(`File: ${filePath}`)
- for (const textEdit of edits) {
- lines.push(formatTextEdit(textEdit))
- }
- }
- }
-
- if (edit.documentChanges) {
- for (const change of edit.documentChanges) {
- if ("kind" in change) {
- if (change.kind === "create") {
- lines.push(`Create: ${change.uri}`)
- } else if (change.kind === "rename") {
- lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`)
- } else if (change.kind === "delete") {
- lines.push(`Delete: ${change.uri}`)
- }
- } else {
- const filePath = uriToPath(change.textDocument.uri)
- lines.push(`File: ${filePath}`)
- for (const textEdit of change.edits) {
- lines.push(formatTextEdit(textEdit))
- }
- }
- }
- }
-
- if (lines.length === 0) return "No changes"
-
- return lines.join("\n")
-}
-
-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")
-}
diff --git a/src/tools/lsp/lsp-manager-process-cleanup.ts b/src/tools/lsp/lsp-manager-process-cleanup.ts
deleted file mode 100644
index 4bf6b14f7..000000000
--- a/src/tools/lsp/lsp-manager-process-cleanup.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { log } from "../../shared/logger"
-
-type ManagedClientForCleanup = {
- client: {
- stop: () => Promise;
- };
-};
-
-type ProcessCleanupOptions = {
- getClients: () => IterableIterator<[string, ManagedClientForCleanup]>;
- clearClients: () => void;
- clearCleanupInterval: () => void;
-};
-
-type RegisteredHandler = {
- event: string;
- listener: (...args: unknown[]) => void;
-};
-
-export type LspProcessCleanupHandle = {
- unregister: () => void;
-};
-
-export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
- const handlers: RegisteredHandler[] = [];
-
- const logCleanupError = (phase: string, error: unknown): void => {
- log(`[lsp-manager-process-cleanup] ${phase}`, {
- error: error instanceof Error ? error.message : String(error),
- });
- };
-
- const syncCleanup = () => {
- for (const [, managed] of options.getClients()) {
- try {
- void managed.client.stop().catch((error) => {
- logCleanupError("stop failed during exit cleanup", error);
- });
- } catch (error) {
- logCleanupError("failed to schedule exit cleanup", error);
- }
- }
- options.clearClients();
- options.clearCleanupInterval();
- };
-
- const asyncCleanup = async () => {
- const stopPromises: Promise[] = [];
- for (const [, managed] of options.getClients()) {
- stopPromises.push(managed.client.stop().catch((error) => {
- logCleanupError("stop failed during signal cleanup", error);
- }));
- }
- await Promise.allSettled(stopPromises);
- options.clearClients();
- options.clearCleanupInterval();
- };
-
- const registerHandler = (event: string, listener: (...args: unknown[]) => void) => {
- handlers.push({ event, listener });
- process.on(event, listener);
- };
-
- registerHandler("exit", syncCleanup);
-
- const signalCleanup = () => void asyncCleanup().catch((error) => {
- logCleanupError("signal cleanup failed", error);
- });
- registerHandler("SIGINT", signalCleanup);
- registerHandler("SIGTERM", signalCleanup);
- if (process.platform === "win32") {
- registerHandler("SIGBREAK", signalCleanup);
- }
-
- return {
- unregister: () => {
- for (const { event, listener } of handlers) {
- process.off(event, listener);
- }
- handlers.length = 0;
- },
- };
-}
diff --git a/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts b/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts
deleted file mode 100644
index 5ce5aa979..000000000
--- a/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-type ManagedClientForTempDirectoryCleanup = {
- refCount: number
- client: {
- stop: () => Promise
- }
-}
-
-export async function cleanupTempDirectoryLspClients(
- clients: Map
-): Promise {
- const keysToRemove: string[] = []
- for (const [key, managed] of clients.entries()) {
- const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/")
- const isIdle = managed.refCount === 0
- if (isTempDir && isIdle) {
- keysToRemove.push(key)
- }
- }
-
- for (const key of keysToRemove) {
- const managed = clients.get(key)
- if (managed) {
- clients.delete(key)
- try {
- await managed.client.stop()
- } catch {}
- }
- }
-}
diff --git a/src/tools/lsp/lsp-process.test.ts b/src/tools/lsp/lsp-process.test.ts
deleted file mode 100644
index a4ecbc0d9..000000000
--- a/src/tools/lsp/lsp-process.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { mkdtempSync, rmSync } from "node:fs"
-import { tmpdir } from "node:os"
-import { join } from "node:path"
-
-import { describe, expect, it, spyOn } from "bun:test"
-
-describe("spawnProcess", () => {
- it("proceeds to node spawn on Windows when command is available", async () => {
- //#given
- const originalPlatform = process.platform
- const rootDir = mkdtempSync(join(tmpdir(), "lsp-process-test-"))
- const childProcess = await import("node:child_process")
- const nodeSpawnSpy = spyOn(childProcess, "spawn")
-
- try {
- Object.defineProperty(process, "platform", { value: "win32" })
- const { spawnProcess } = await import("./lsp-process")
-
- //#when
- let result: ReturnType | null = null
- expect(() => {
- result = spawnProcess(["node", "--version"], {
- cwd: rootDir,
- env: process.env,
- })
- }).not.toThrow(/Binary 'node' not found/)
-
- //#then
- expect(nodeSpawnSpy).toHaveBeenCalled()
- expect(result).not.toBeNull()
- } finally {
- Object.defineProperty(process, "platform", { value: originalPlatform })
- nodeSpawnSpy.mockRestore()
- rmSync(rootDir, { recursive: true, force: true })
- }
- })
-})
diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts
deleted file mode 100644
index 634e66b2b..000000000
--- a/src/tools/lsp/lsp-process.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { spawn as bunSpawn, type SpawnedProcess } from "../../shared/bun-spawn-shim"
-import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
-import { existsSync, statSync } from "fs"
-import { log } from "../../shared/logger"
-function shouldUseNodeSpawn(): boolean {
- return process.platform === "win32"
-}
-export function validateCwd(cwd: string): { valid: boolean; error?: string } {
- try {
- if (!existsSync(cwd)) {
- return { valid: false, error: `Working directory does not exist: ${cwd}` }
- }
- const stats = statSync(cwd)
- if (!stats.isDirectory()) {
- return { valid: false, error: `Path is not a directory: ${cwd}` }
- }
- return { valid: true }
- } catch (err) {
- return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` }
- }
-}
-interface StreamReader {
- read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
-}
-export interface UnifiedProcess {
- stdin: { write(chunk: Uint8Array | string): void }
- stdout: { getReader(): StreamReader }
- stderr: { getReader(): StreamReader }
- exitCode: number | null
- exited: Promise
- kill(signal?: string): void
-}
-function wrapNodeProcess(proc: ChildProcess): UnifiedProcess {
- let resolveExited: (code: number) => void
- let exitCode: number | null = null
- const exitedPromise = new Promise((resolve) => {
- resolveExited = resolve
- })
- proc.on("exit", (code) => {
- exitCode = code ?? 1
- resolveExited(exitCode)
- })
- proc.on("error", () => {
- if (exitCode === null) {
- exitCode = 1
- resolveExited(1)
- }
- })
- const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => {
- const chunks: Uint8Array[] = []
- let streamEnded = false
- type ReadResult = { done: boolean; value: Uint8Array | undefined }
- let waitingResolve: ((result: ReadResult) => void) | null = null
-
- if (nodeStream) {
- nodeStream.on("data", (chunk: Buffer) => {
- const uint8 = new Uint8Array(chunk)
- if (waitingResolve) {
- const resolve = waitingResolve
- waitingResolve = null
- resolve({ done: false, value: uint8 })
- } else {
- chunks.push(uint8)
- }
- })
-
- nodeStream.on("end", () => {
- streamEnded = true
- if (waitingResolve) {
- const resolve = waitingResolve
- waitingResolve = null
- resolve({ done: true, value: undefined })
- }
- })
-
- nodeStream.on("error", () => {
- streamEnded = true
- if (waitingResolve) {
- const resolve = waitingResolve
- waitingResolve = null
- resolve({ done: true, value: undefined })
- }
- })
- } else {
- streamEnded = true
- }
- return {
- read(): Promise {
- return new Promise((resolve) => {
- if (chunks.length > 0) {
- resolve({ done: false, value: chunks.shift()! })
- } else if (streamEnded) {
- resolve({ done: true, value: undefined })
- } else {
- waitingResolve = resolve
- }
- })
- },
- }
- }
- return {
- stdin: {
- write(chunk: Uint8Array | string) {
- if (proc.stdin) {
- proc.stdin.write(chunk)
- }
- },
- },
- stdout: {
- getReader: () => createStreamReader(proc.stdout),
- },
- stderr: {
- getReader: () => createStreamReader(proc.stderr),
- },
- get exitCode() {
- return exitCode
- },
- exited: exitedPromise,
- kill(signal?: string) {
- try {
- if (signal === "SIGKILL") {
- proc.kill("SIGKILL")
- } else {
- proc.kill()
- }
- } catch {}
- },
- }
-}
-
-function wrapBunProcess(proc: SpawnedProcess): UnifiedProcess {
- return {
- stdin: {
- write(chunk: Uint8Array | string) {
- proc.stdin.write(chunk)
- },
- },
- stdout: {
- getReader: () => proc.stdout.getReader(),
- },
- stderr: {
- getReader: () => proc.stderr.getReader(),
- },
- get exitCode() {
- return proc.exitCode
- },
- exited: proc.exited,
- kill(signal?: string) {
- proc.kill(signal === "SIGKILL" ? "SIGKILL" : undefined)
- },
- }
-}
-
-export function spawnProcess(
- command: string[],
- options: { cwd: string; env: Record }
-): UnifiedProcess {
- const cwdValidation = validateCwd(options.cwd)
- if (!cwdValidation.valid) {
- throw new Error(`[LSP] ${cwdValidation.error}`)
- }
- if (shouldUseNodeSpawn()) {
- const [cmd, ...args] = command
- log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault")
- const proc = nodeSpawn(cmd, args, {
- cwd: options.cwd,
- env: options.env as NodeJS.ProcessEnv,
- stdio: ["pipe", "pipe", "pipe"],
- windowsHide: true,
- shell: true,
- })
- return wrapNodeProcess(proc)
- }
- const proc = bunSpawn(command, {
- stdin: "pipe",
- stdout: "pipe",
- stderr: "pipe",
- cwd: options.cwd,
- env: options.env,
- })
- return wrapBunProcess(proc)
-}
diff --git a/src/tools/lsp/lsp-server.ts b/src/tools/lsp/lsp-server.ts
deleted file mode 100644
index 4a70a8855..000000000
--- a/src/tools/lsp/lsp-server.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { LSPClient } from "./lsp-client";
-import { registerLspManagerProcessCleanup, type LspProcessCleanupHandle } from "./lsp-manager-process-cleanup";
-import { cleanupTempDirectoryLspClients } from "./lsp-manager-temp-directory-cleanup";
-import type { ResolvedServer } from "./types";
-interface ManagedClient {
- client: LSPClient;
- lastUsedAt: number;
- refCount: number;
- initPromise?: Promise;
- isInitializing: boolean;
- initializingSince?: number;
-}
-class LSPServerManager {
- private static instance: LSPServerManager;
- private clients = new Map();
- private cleanupInterval: ReturnType | null = null;
- private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
- private readonly INIT_TIMEOUT = 60 * 1000;
- private cleanupHandle: LspProcessCleanupHandle | null = null;
- private constructor() {
- this.startCleanupTimer();
- this.registerProcessCleanup();
- }
- private registerProcessCleanup(): void {
- this.cleanupHandle = registerLspManagerProcessCleanup({
- getClients: () => this.clients.entries(),
- clearClients: () => {
- this.clients.clear();
- },
- clearCleanupInterval: () => {
- if (this.cleanupInterval) {
- clearInterval(this.cleanupInterval);
- this.cleanupInterval = null;
- }
- },
- });
- }
-
- 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);
- if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
- this.cleanupInterval.unref();
- }
- }
-
- 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 {
- const key = this.getKey(root, server.id);
- let managed = this.clients.get(key);
- if (managed) {
- const now = Date.now();
- if (
- managed.isInitializing &&
- managed.initializingSince !== undefined &&
- now - managed.initializingSince >= this.INIT_TIMEOUT
- ) {
- // Stale init can permanently block subsequent calls (e.g., LSP process hang)
- try {
- await managed.client.stop();
- } catch {}
- this.clients.delete(key);
- managed = undefined;
- }
- }
- if (managed) {
- if (managed.initPromise) {
- try {
- await managed.initPromise;
- } catch {
- // Failed init should not keep the key blocked forever.
- try {
- await managed.client.stop();
- } catch {}
- this.clients.delete(key);
- managed = undefined;
- }
- }
-
- if (managed) {
- if (managed.client.isAlive()) {
- managed.refCount++;
- managed.lastUsedAt = Date.now();
- return managed.client;
- }
- try {
- await managed.client.stop();
- } catch {}
- this.clients.delete(key);
- }
- }
-
- const client = new LSPClient(root, server);
- const initPromise = (async () => {
- await client.start();
- await client.initialize();
- })();
- const initStartedAt = Date.now();
- this.clients.set(key, {
- client,
- lastUsedAt: initStartedAt,
- refCount: 1,
- initPromise,
- isInitializing: true,
- initializingSince: initStartedAt,
- });
-
- try {
- await initPromise;
- } catch (error) {
- this.clients.delete(key);
- try {
- await client.stop();
- } catch {}
- throw error;
- }
- const m = this.clients.get(key);
- if (m) {
- m.initPromise = undefined;
- m.isInitializing = false;
- m.initializingSince = undefined;
- }
-
- return client;
- }
-
- warmupClient(root: string, server: ResolvedServer): void {
- const key = this.getKey(root, server.id);
- if (this.clients.has(key)) return;
- const client = new LSPClient(root, server);
- const initPromise = (async () => {
- await client.start();
- await client.initialize();
- })();
-
- const initStartedAt = Date.now();
- this.clients.set(key, {
- client,
- lastUsedAt: initStartedAt,
- refCount: 0,
- initPromise,
- isInitializing: true,
- initializingSince: initStartedAt,
- });
-
- initPromise
- .then(() => {
- const m = this.clients.get(key);
- if (m) {
- m.initPromise = undefined;
- m.isInitializing = false;
- m.initializingSince = undefined;
- }
- })
- .catch(() => {
- // Warmup failures must not permanently block future initialization.
- this.clients.delete(key);
- void client.stop().catch(() => {});
- });
- }
-
- 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();
- }
- }
-
- 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 {
- this.cleanupHandle?.unregister();
- this.cleanupHandle = null;
- for (const [, managed] of this.clients) {
- await managed.client.stop();
- }
- this.clients.clear();
- if (this.cleanupInterval) {
- clearInterval(this.cleanupInterval);
- this.cleanupInterval = null;
- }
- }
-
- async cleanupTempDirectoryClients(): Promise {
- await cleanupTempDirectoryLspClients(this.clients);
- }
-}
-
-export const lspManager = LSPServerManager.getInstance();
diff --git a/src/tools/lsp/rename-tools.ts b/src/tools/lsp/rename-tools.ts
deleted file mode 100644
index d49cfebb8..000000000
--- a/src/tools/lsp/rename-tools.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-
-import { formatApplyResult, formatPrepareRenameResult } from "./lsp-formatters"
-import { withLspClient } from "./lsp-client-wrapper"
-import { applyWorkspaceEdit } from "./workspace-edit"
-import type { PrepareRenameDefaultBehavior, PrepareRenameResult, WorkspaceEdit } from "./types"
-
-export const lsp_prepare_rename: ToolDefinition = tool({
- description: "Check if rename is valid. Use BEFORE lsp_rename.",
- args: {
- filePath: tool.schema.string(),
- line: tool.schema.number().min(1).describe("1-based"),
- character: tool.schema.number().min(0).describe("0-based"),
- },
- execute: async (args, _context) => {
- try {
- const result = await withLspClient(args.filePath, async (client) => {
- return (await client.prepareRename(args.filePath, args.line, args.character)) as
- | PrepareRenameResult
- | PrepareRenameDefaultBehavior
- | null
- })
- const output = formatPrepareRenameResult(result)
- return output
- } catch (e) {
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`
- return output
- }
- },
-})
-
-export const lsp_rename: ToolDefinition = tool({
- description: "Rename symbol across entire workspace. APPLIES changes to all files.",
- args: {
- filePath: tool.schema.string(),
- line: tool.schema.number().min(1).describe("1-based"),
- character: tool.schema.number().min(0).describe("0-based"),
- newName: tool.schema.string().describe("New symbol name"),
- },
- execute: async (args, _context) => {
- 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)
- const output = formatApplyResult(result)
- return output
- } catch (e) {
- const output = `Error: ${e instanceof Error ? e.message : String(e)}`
- return output
- }
- },
-})
diff --git a/src/tools/lsp/server-config-loader.test.ts b/src/tools/lsp/server-config-loader.test.ts
deleted file mode 100644
index 1d8937ab4..000000000
--- a/src/tools/lsp/server-config-loader.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { describe, it, expect } from "bun:test"
-import { writeFileSync, unlinkSync, mkdirSync, rmSync } from "fs"
-import { join } from "path"
-import { tmpdir } from "os"
-import { loadJsonFile, getConfigPaths, getMergedServers } from "./server-config-loader"
-
-describe("loadJsonFile", () => {
- it("parses JSONC config files with comments correctly", () => {
- // given
- const testData = {
- lsp: {
- typescript: {
- command: ["tsserver"],
- extensions: [".ts", ".tsx"]
- }
- }
- }
- const jsoncContent = `{
- // LSP configuration for TypeScript
- "lsp": {
- "typescript": {
- "command": ["tsserver"],
- "extensions": [".ts", ".tsx"] // TypeScript extensions
- }
- }
-}`
- const tempPath = join(tmpdir(), "test-config.jsonc")
- writeFileSync(tempPath, jsoncContent, "utf-8")
-
- // when
- const result = loadJsonFile(tempPath)
-
- // then
- expect(result).toEqual(testData)
-
- // cleanup
- unlinkSync(tempPath)
- })
-
- it("discovers JSONC-only user config (oh-my-opencode.jsonc)", () => {
- const originalEnv = process.env.OPENCODE_CONFIG_DIR
- const tempBase = join(tmpdir(), `omo-test-user-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`)
- try {
- mkdirSync(tempBase, { recursive: true })
- process.env.OPENCODE_CONFIG_DIR = tempBase
-
- const userJsonc = `{
- // user jsonc config
- "lsp": {
- "user-jsonc": {
- "command": ["user-jsonc-cmd"],
- "extensions": [".ujs"]
- }
- }
-}`
- const userPath = join(tempBase, "oh-my-opencode.jsonc")
- writeFileSync(userPath, userJsonc, "utf-8")
-
- const servers = getMergedServers()
- const found = servers.find(s => s.id === "user-jsonc" && s.source === "user")
- expect(found !== undefined).toBe(true)
- } finally {
- if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR
- else process.env.OPENCODE_CONFIG_DIR = originalEnv
- rmSync(tempBase, { recursive: true, force: true })
- }
- })
-
- it("discovers JSONC-only opencode config (opencode.jsonc)", () => {
- const originalEnv = process.env.OPENCODE_CONFIG_DIR
- const tempBase = join(tmpdir(), `omo-test-oc-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`)
- try {
- mkdirSync(tempBase, { recursive: true })
- process.env.OPENCODE_CONFIG_DIR = tempBase
-
- const opencodeJsonc = `{
- // opencode jsonc config
- "lsp": {
- "opencode-jsonc": {
- "command": ["opencode-jsonc-cmd"],
- "extensions": [".ocjs"]
- }
- }
-}`
- const opencodePath = join(tempBase, "opencode.jsonc")
- writeFileSync(opencodePath, opencodeJsonc, "utf-8")
-
- const servers = getMergedServers()
- const found = servers.find(s => s.id === "opencode-jsonc" && s.source === "opencode")
- expect(found !== undefined).toBe(true)
- } finally {
- if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR
- else process.env.OPENCODE_CONFIG_DIR = originalEnv
- rmSync(tempBase, { recursive: true, force: true })
- }
- })
-
- it("discovers JSONC-only project config (.opencode/oh-my-opencode.jsonc)", () => {
- const originalCwd = process.cwd()
- const tempProject = join(tmpdir(), `omo-test-project-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`)
- try {
- mkdirSync(join(tempProject, ".opencode"), { recursive: true })
- const projectJsonc = `{
- // project jsonc config
- "lsp": {
- "project-jsonc": {
- "command": ["project-jsonc-cmd"],
- "extensions": [".pjs"]
- }
- }
-}`
- const projectPath = join(tempProject, ".opencode", "oh-my-opencode.jsonc")
- writeFileSync(projectPath, projectJsonc, "utf-8")
-
- process.chdir(tempProject)
- const servers = getMergedServers()
- const found = servers.find(s => s.id === "project-jsonc" && s.source === "project")
- expect(found !== undefined).toBe(true)
- } finally {
- process.chdir(originalCwd)
- rmSync(tempProject, { recursive: true, force: true })
- }
- })
-
- it("prefers .jsonc over .json when both exist for same config id", () => {
- const originalEnv = process.env.OPENCODE_CONFIG_DIR
- const tempBase = join(tmpdir(), `omo-test-precedence-${Date.now()}-${Math.random().toString(36).slice(2)}`)
- try {
- mkdirSync(tempBase, { recursive: true })
- process.env.OPENCODE_CONFIG_DIR = tempBase
-
- const jsonContent = `{
- "lsp": {
- "conflict": {
- "command": ["from-json"],
- "extensions": [".j"]
- }
- }
-}`
- const jsoncContent = `{
- // jsonc should take precedence
- "lsp": {
- "conflict": {
- "command": ["from-jsonc"],
- "extensions": [".jc"]
- }
- }
-}`
- writeFileSync(join(tempBase, "oh-my-opencode.json"), jsonContent, "utf-8")
- writeFileSync(join(tempBase, "oh-my-opencode.jsonc"), jsoncContent, "utf-8")
-
- const servers = getMergedServers()
- const found = servers.find(s => s.id === "conflict" && s.source === "user")
- expect(found?.command && Array.isArray(found.command) && found.command[0] === "from-jsonc").toBe(true)
- } finally {
- if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR
- else process.env.OPENCODE_CONFIG_DIR = originalEnv
- rmSync(tempBase, { recursive: true, force: true })
- }
- })
-})
diff --git a/src/tools/lsp/server-config-loader.ts b/src/tools/lsp/server-config-loader.ts
deleted file mode 100644
index 9cc38d9b8..000000000
--- a/src/tools/lsp/server-config-loader.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { existsSync, readFileSync } from "fs"
-import { join } from "path"
-
-import { BUILTIN_SERVERS } from "./constants"
-import type { ResolvedServer } from "./types"
-import { getOpenCodeConfigDir } from "../../shared"
-import { parseJsonc, detectConfigFile, detectPluginConfigFile } from "../../shared/jsonc-parser"
-
-interface LspEntry {
- disabled?: boolean
- command?: string[]
- extensions?: string[]
- priority?: number
- env?: Record
- initialization?: Record
-}
-
-interface ConfigJson {
- lsp?: Record
-}
-
-type ConfigSource = "project" | "user" | "opencode"
-
-interface ServerWithSource extends ResolvedServer {
- source: ConfigSource
-}
-
-export function loadJsonFile(path: string): T | null {
- if (!existsSync(path)) return null
- try {
- return parseJsonc(readFileSync(path, "utf-8")) as T
- } catch {
- return null
- }
-}
-
-export function getConfigPaths(): { project: string; user: string; opencode: string } {
- const cwd = process.cwd()
- const configDir = getOpenCodeConfigDir({ binary: "opencode" })
- return {
- project: detectPluginConfigFile(join(cwd, ".opencode")).path,
- user: detectPluginConfigFile(configDir).path,
- opencode: detectConfigFile(join(configDir, "opencode")).path,
- }
-}
-
-export function loadAllConfigs(): Map {
- const paths = getConfigPaths()
- const configs = new Map()
-
- const project = loadJsonFile(paths.project)
- if (project) configs.set("project", project)
-
- const user = loadJsonFile(paths.user)
- if (user) configs.set("user", user)
-
- const opencode = loadJsonFile(paths.opencode)
- if (opencode) configs.set("opencode", opencode)
-
- return configs
-}
-
-export function getMergedServers(): ServerWithSource[] {
- const configs = loadAllConfigs()
- const servers: ServerWithSource[] = []
- const disabled = new Set()
- const seen = new Set()
-
- const sources: ConfigSource[] = ["project", "user", "opencode"]
-
- for (const source of sources) {
- const config = configs.get(source)
- if (!config?.lsp) continue
-
- for (const [id, entry] of Object.entries(config.lsp)) {
- if (entry.disabled) {
- disabled.add(id)
- continue
- }
-
- if (seen.has(id)) continue
- if (!entry.command || !entry.extensions) continue
-
- servers.push({
- id,
- command: entry.command,
- extensions: entry.extensions,
- priority: entry.priority ?? 0,
- env: entry.env,
- initialization: entry.initialization,
- source,
- })
- seen.add(id)
- }
- }
-
- for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
- if (disabled.has(id) || seen.has(id)) continue
-
- servers.push({
- id,
- command: config.command,
- extensions: config.extensions,
- priority: -100,
- source: "opencode",
- })
- }
-
- return servers.sort((a, b) => {
- if (a.source !== b.source) {
- const order: Record = { project: 0, user: 1, opencode: 2 }
- return order[a.source] - order[b.source]
- }
- return b.priority - a.priority
- })
-}
diff --git a/src/tools/lsp/server-definitions.ts b/src/tools/lsp/server-definitions.ts
deleted file mode 100644
index 0e00f1395..000000000
--- a/src/tools/lsp/server-definitions.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { LSPServerConfig } from "./types"
-
-export const LSP_INSTALL_HINTS: Record = {
- typescript: "npm install -g typescript-language-server typescript",
- deno: "Install Deno from https://deno.land",
- vue: "npm install -g @vue/language-server",
- eslint: "npm install -g vscode-langservers-extracted",
- oxlint: "npm install -g oxlint",
- biome: "npm install -g @biomejs/biome",
- gopls: "go install golang.org/x/tools/gopls@latest",
- "ruby-lsp": "gem install ruby-lsp",
- basedpyright: "pip install basedpyright",
- pyright: "pip install pyright",
- ty: "pip install ty",
- ruff: "pip install ruff",
- "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
- zls: "See https://github.com/zigtools/zls",
- csharp: "dotnet tool install -g csharp-ls",
- fsharp: "dotnet tool install -g fsautocomplete",
- "sourcekit-lsp": "Included with Xcode or Swift toolchain",
- rust: "rustup component add rust-analyzer",
- clangd: "See https://clangd.llvm.org/installation",
- svelte: "npm install -g svelte-language-server",
- astro: "npm install -g @astrojs/language-server",
- "bash-ls": "npm install -g bash-language-server",
- jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
- "yaml-ls": "npm install -g yaml-language-server",
- "lua-ls": "See https://github.com/LuaLS/lua-language-server",
- php: "npm install -g intelephense",
- dart: "Included with Dart SDK",
- "terraform-ls": "See https://github.com/hashicorp/terraform-ls",
- terraform: "See https://github.com/hashicorp/terraform-ls",
- prisma: "npm install -g prisma",
- "ocaml-lsp": "opam install ocaml-lsp-server",
- texlab: "See https://github.com/latex-lsp/texlab",
- dockerfile: "npm install -g dockerfile-language-server-nodejs",
- gleam: "See https://gleam.run/getting-started/installing/",
- "clojure-lsp": "See https://clojure-lsp.io/installation/",
- nixd: "nix profile install nixpkgs#nixd",
- tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
- "haskell-language-server": "ghcup install hls",
- bash: "npm install -g bash-language-server",
- "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
-}
-
-// Synced with OpenCode's server.ts
-// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
-export const BUILTIN_SERVERS: Record> = {
- typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] },
- deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] },
- vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] },
- eslint: { command: ["vscode-eslint-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"] },
- oxlint: { command: ["oxlint", "--lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"] },
- biome: { command: ["biome", "lsp-proxy", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html"] },
- gopls: { command: ["gopls"], extensions: [".go"] },
- "ruby-lsp": { command: ["rubocop", "--lsp"], extensions: [".rb", ".rake", ".gemspec", ".ru"] },
- basedpyright: { command: ["basedpyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
- pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] },
- ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] },
- ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] },
- "elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] },
- zls: { command: ["zls"], extensions: [".zig", ".zon"] },
- csharp: { command: ["csharp-ls"], extensions: [".cs"] },
- fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] },
- "sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] },
- rust: { command: ["rust-analyzer"], extensions: [".rs"] },
- clangd: { command: ["clangd", "--background-index", "--clang-tidy"], extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"] },
- svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] },
- astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] },
- bash: { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] },
- // Keep legacy alias for backward compatibility
- "bash-ls": { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] },
- jdtls: { command: ["jdtls"], extensions: [".java"] },
- "yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] },
- "lua-ls": { command: ["lua-language-server"], extensions: [".lua"] },
- php: { command: ["intelephense", "--stdio"], extensions: [".php"] },
- dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] },
- terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
- // Legacy alias for backward compatibility
- "terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] },
- prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] },
- "ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] },
- texlab: { command: ["texlab"], extensions: [".tex", ".bib"] },
- dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] },
- gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] },
- "clojure-lsp": { command: ["clojure-lsp", "listen"], extensions: [".clj", ".cljs", ".cljc", ".edn"] },
- nixd: { command: ["nixd"], extensions: [".nix"] },
- tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] },
- "haskell-language-server": { command: ["haskell-language-server-wrapper", "--lsp"], extensions: [".hs", ".lhs"] },
- "kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] },
-}
diff --git a/src/tools/lsp/server-installation.ts b/src/tools/lsp/server-installation.ts
deleted file mode 100644
index 9e26eee7a..000000000
--- a/src/tools/lsp/server-installation.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { existsSync } from "fs"
-import { delimiter, join } from "path"
-
-import { getLspServerAdditionalPathBases } from "./server-path-bases"
-
-export function isServerInstalled(command: string[]): boolean {
- if (command.length === 0) return false
-
- const cmd = command[0]
-
- // Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server)
- if (cmd.includes("/") || cmd.includes("\\")) {
- if (existsSync(cmd)) return true
- }
-
- const isWindows = process.platform === "win32"
-
- let exts = [""]
- if (isWindows) {
- const pathExt = process.env.PATHEXT || ""
- if (pathExt) {
- const systemExts = pathExt.split(";").filter(Boolean)
- exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])]
- } else {
- exts = ["", ".exe", ".cmd", ".bat", ".ps1"]
- }
- }
-
- let pathEnv = process.env.PATH || ""
- if (isWindows && !pathEnv) {
- pathEnv = process.env.Path || ""
- }
-
- const paths = pathEnv.split(delimiter)
-
- for (const p of paths) {
- for (const suffix of exts) {
- if (existsSync(join(p, cmd + suffix))) {
- return true
- }
- }
- }
-
- for (const base of getLspServerAdditionalPathBases(process.cwd())) {
- for (const suffix of exts) {
- if (existsSync(join(base, cmd + suffix))) {
- return true
- }
- }
- }
-
- // Runtime wrappers (bun/node) are always available in oh-my-opencode context
- if (cmd === "bun" || cmd === "node") {
- return true
- }
-
- return false
-}
diff --git a/src/tools/lsp/server-path-bases.ts b/src/tools/lsp/server-path-bases.ts
deleted file mode 100644
index 7f6b00490..000000000
--- a/src/tools/lsp/server-path-bases.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { join } from "path"
-
-import { getDataDir, getOpenCodeConfigDir } from "../../shared"
-
-export function getLspServerAdditionalPathBases(workingDirectory: string): string[] {
- const configDir = getOpenCodeConfigDir({ binary: "opencode" })
- const dataDir = join(getDataDir(), "opencode")
-
- return [
- join(workingDirectory, "node_modules", ".bin"),
- join(configDir, "bin"),
- join(configDir, "node_modules", ".bin"),
- join(dataDir, "bin"),
- join(dataDir, "bin", "node_modules", ".bin"),
- ]
-}
diff --git a/src/tools/lsp/server-resolution.ts b/src/tools/lsp/server-resolution.ts
deleted file mode 100644
index 4279110e6..000000000
--- a/src/tools/lsp/server-resolution.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./constants"
-import { getConfigPaths, getMergedServers, loadAllConfigs } from "./server-config-loader"
-import { isServerInstalled } from "./server-installation"
-import type { ServerLookupResult } from "./types"
-
-export function findServerForExtension(ext: string): ServerLookupResult {
- const servers = getMergedServers()
-
- for (const server of servers) {
- if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
- return {
- status: "found",
- server: {
- id: server.id,
- command: server.command,
- extensions: server.extensions,
- priority: server.priority,
- env: server.env,
- initialization: server.initialization,
- },
- }
- }
- }
-
- for (const server of servers) {
- if (server.extensions.includes(ext)) {
- const installHint = LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH`
- return {
- status: "not_installed",
- server: {
- id: server.id,
- command: server.command,
- extensions: server.extensions,
- },
- installHint,
- }
- }
- }
-
- const availableServers = [...new Set(servers.map((s) => s.id))]
- return {
- status: "not_configured",
- extension: ext,
- availableServers,
- }
-}
-
-export function getAllServers(): Array<{
- id: string
- installed: boolean
- extensions: string[]
- disabled: boolean
- source: string
- priority: number
-}> {
- const configs = loadAllConfigs()
- const servers = getMergedServers()
- const disabled = new Set()
-
- for (const config of configs.values()) {
- if (!config.lsp) continue
- for (const [id, entry] of Object.entries(config.lsp)) {
- if (entry.disabled) disabled.add(id)
- }
- }
-
- const result: Array<{
- id: string
- installed: boolean
- extensions: string[]
- disabled: boolean
- source: string
- priority: number
- }> = []
-
- const seen = new Set()
-
- for (const server of servers) {
- if (seen.has(server.id)) continue
- result.push({
- id: server.id,
- installed: isServerInstalled(server.command),
- extensions: server.extensions,
- disabled: false,
- source: server.source,
- priority: server.priority,
- })
- seen.add(server.id)
- }
-
- for (const id of disabled) {
- if (seen.has(id)) continue
- const builtin = BUILTIN_SERVERS[id]
- result.push({
- id,
- installed: builtin ? isServerInstalled(builtin.command) : false,
- extensions: builtin?.extensions || [],
- disabled: true,
- source: "disabled",
- priority: 0,
- })
- }
-
- return result
-}
-
-export function getConfigPaths_(): { project: string; user: string; opencode: string } {
- return getConfigPaths()
-}
diff --git a/src/tools/lsp/symbols-tool.ts b/src/tools/lsp/symbols-tool.ts
deleted file mode 100644
index 3af960731..000000000
--- a/src/tools/lsp/symbols-tool.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-
-import { DEFAULT_MAX_SYMBOLS } from "./constants"
-import { formatDocumentSymbol, formatSymbolInfo } from "./lsp-formatters"
-import { withLspClient } from "./lsp-client-wrapper"
-import type { DocumentSymbol, SymbolInfo } from "./types"
-
-export const lsp_symbols: ToolDefinition = tool({
- description:
- "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.",
- args: {
- filePath: tool.schema.string().describe("File path for LSP context"),
- scope: tool.schema
- .enum(["document", "workspace"])
- .default("document")
- .describe("'document' for file symbols, 'workspace' for project-wide search"),
- query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"),
- limit: tool.schema.number().optional().describe("Max results (default 50)"),
- },
- execute: async (args, _context) => {
- try {
- const scope = args.scope ?? "document"
-
- if (scope === "workspace") {
- const query = args.query
- if (!query) {
- return "Error: 'query' is required for workspace scope"
- }
-
- const result = await withLspClient(args.filePath, async (client) => {
- return (await client.workspaceSymbols(query)) as SymbolInfo[] | null
- })
-
- if (!result || result.length === 0) {
- return "No symbols found"
- }
-
- const total = result.length
- const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
- const truncated = total > limit
- const limited = result.slice(0, limit)
- const lines = limited.map(formatSymbolInfo)
- if (truncated) {
- lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
- }
- return lines.join("\n")
- } else {
- const result = await withLspClient(args.filePath, async (client) => {
- return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null
- })
-
- if (!result || result.length === 0) {
- return "No symbols found"
- }
-
- const total = result.length
- const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
- const truncated = total > limit
- const limited = truncated ? result.slice(0, limit) : result
-
- const lines: string[] = []
- if (truncated) {
- lines.push(`Found ${total} symbols (showing first ${limit}):`)
- }
-
- if ("range" in limited[0]) {
- lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
- } else {
- lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
- }
- return lines.join("\n")
- }
- } catch (e) {
- return `Error: ${e instanceof Error ? e.message : String(e)}`
- }
- },
-})
diff --git a/src/tools/lsp/tools.ts b/src/tools/lsp/tools.ts
deleted file mode 100644
index 9ed6ff7b3..000000000
--- a/src/tools/lsp/tools.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { lsp_goto_definition } from "./goto-definition-tool"
-export { lsp_find_references } from "./find-references-tool"
-export { lsp_symbols } from "./symbols-tool"
-export { lsp_diagnostics } from "./diagnostics-tool"
-export { lsp_prepare_rename, lsp_rename } from "./rename-tools"
diff --git a/src/tools/lsp/types.ts b/src/tools/lsp/types.ts
deleted file mode 100644
index 6a7c1ddfc..000000000
--- a/src/tools/lsp/types.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-export interface LSPServerConfig {
- id: string
- command: string[]
- extensions: string[]
- disabled?: boolean
- env?: Record
- initialization?: Record
-}
-
-export interface Position {
- line: number
- character: number
-}
-
-export interface Range {
- start: Position
- end: Position
-}
-
-export interface Location {
- uri: string
- range: Range
-}
-
-export interface LocationLink {
- targetUri: string
- targetRange: Range
- targetSelectionRange: Range
- originSelectionRange?: Range
-}
-
-export interface SymbolInfo {
- name: string
- kind: number
- location: Location
- containerName?: string
-}
-
-export interface DocumentSymbol {
- name: string
- kind: number
- range: Range
- selectionRange: Range
- children?: DocumentSymbol[]
-}
-
-export interface Diagnostic {
- range: Range
- severity?: number
- code?: string | number
- source?: string
- message: string
-}
-
-export interface TextDocumentIdentifier {
- uri: string
-}
-
-export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
- version: number | null
-}
-
-export interface TextEdit {
- range: Range
- newText: string
-}
-
-export interface TextDocumentEdit {
- textDocument: VersionedTextDocumentIdentifier
- edits: TextEdit[]
-}
-
-export interface CreateFile {
- kind: "create"
- uri: string
- options?: { overwrite?: boolean; ignoreIfExists?: boolean }
-}
-
-export interface RenameFile {
- kind: "rename"
- oldUri: string
- newUri: string
- options?: { overwrite?: boolean; ignoreIfExists?: boolean }
-}
-
-export interface DeleteFile {
- kind: "delete"
- uri: string
- options?: { recursive?: boolean; ignoreIfNotExists?: boolean }
-}
-
-export interface WorkspaceEdit {
- changes?: { [uri: string]: TextEdit[] }
- documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[]
-}
-
-export interface PrepareRenameResult {
- range: Range
- placeholder?: string
-}
-
-export interface PrepareRenameDefaultBehavior {
- defaultBehavior: boolean
-}
-
-export interface ServerLookupInfo {
- id: string
- command: string[]
- extensions: string[]
-}
-
-export type ServerLookupResult =
- | { status: "found"; server: ResolvedServer }
- | { status: "not_configured"; extension: string; availableServers: string[] }
- | { status: "not_installed"; server: ServerLookupInfo; installHint: string }
-
-export interface ResolvedServer {
- id: string
- command: string[]
- extensions: string[]
- priority: number
- env?: Record
- initialization?: Record
-}
diff --git a/src/tools/lsp/utils.test.ts b/src/tools/lsp/utils.test.ts
deleted file mode 100644
index a323bd872..000000000
--- a/src/tools/lsp/utils.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, expect, it } from "bun:test"
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
-import { tmpdir } from "os"
-import { join } from "path"
-
-import { findWorkspaceRoot } from "./lsp-client-wrapper"
-
-describe("lsp utils", () => {
- describe("findWorkspaceRoot", () => {
- it("returns an existing directory even when the file path points to a non-existent nested path", () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-root-"))
- try {
- // Add a marker so the function can discover the workspace root.
- writeFileSync(join(tmp, "package.json"), "{}")
-
- const nonExistentFile = join(tmp, "does-not-exist", "deep", "file.ts")
- const root = findWorkspaceRoot(nonExistentFile)
-
- expect(root).toBe(tmp)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
-
- it("prefers the nearest marker directory when markers exist above the file", () => {
- const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-marker-"))
- try {
- const repo = join(tmp, "repo")
- const src = join(repo, "src")
- mkdirSync(src, { recursive: true })
-
- writeFileSync(join(repo, "package.json"), "{}")
- const file = join(src, "index.ts")
- writeFileSync(file, "export {}")
-
- expect(findWorkspaceRoot(file)).toBe(repo)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- })
- })
-})
diff --git a/src/tools/lsp/workspace-edit.ts b/src/tools/lsp/workspace-edit.ts
deleted file mode 100644
index e0a836dc2..000000000
--- a/src/tools/lsp/workspace-edit.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { readFileSync, writeFileSync } from "fs"
-
-import { uriToPath } from "./lsp-client-wrapper"
-import type { TextEdit, WorkspaceEdit } from "./types"
-
-export interface ApplyResult {
- success: boolean
- filesModified: string[]
- totalEdits: number
- errors: string[]
-}
-
-function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } {
- try {
- let content = 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"))
- }
- }
-
- 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 function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
- if (!edit) {
- return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }
- }
-
- 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)
- 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 = readFileSync(oldPath, "utf-8")
- writeFileSync(newPath, content, "utf-8")
- require("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)
- require("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}`)
- }
- }
- }
- }
-
- return result
-}
diff --git a/vendor/lsp-tools-mcp b/vendor/lsp-tools-mcp
new file mode 160000
index 000000000..ff2c5145e
--- /dev/null
+++ b/vendor/lsp-tools-mcp
@@ -0,0 +1 @@
+Subproject commit ff2c5145e8333ac3e0eeae4b3dcfdcffe2325134