# inspiration_neovim2.md — nvim-lspconfig scan & pi-lens LSP roadmap

> Comparative analysis of [neovim/nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) `doc/configs.md` (master, 401 server configs at time of scan, response id `mqmmluk7-8095d522`) against pi-lens's current LSP surface. Every recommendation is asserted against the codebase (file:line). Conclusions and priorities are mine.

---

## TL;DR

- **pi-lens currently exposes 37 active servers in `LSP_SERVERS` (35 primary + 2 auxiliary) + 1 orphan** (`ruby-solargraph` is defined at `clients/lsp/server.ts:1432` but is NOT added to the `LSP_SERVERS` array at `clients/lsp/server.ts:2180-2222` — it would be the 36th primary if added; see "Open Finding" at the end). Commented out: `PowerShellServer` — deliberate, `coverage notice` fires instead (see `clients/lsp/server.ts:2189`).
- **nvim-lspconfig ships 401 server configs** — pi-lens covers the high-traffic ~9% and the long tail is a long tail.
- The biggest leverage is **auxiliary LSPs** (warm cross-cutting scanners that follow the `opengrep`/`ast-grep` profile), not primary servers. The pattern is proven and the surface is small (one profile + one server entry + one strategy).
- pi-lens's LSP **lifecycle is more focused than nvim's** (single per-edit `touchFile` vs nvim's autocmd-based attach/detach). The trade-off is intentional and worth defending — but a few nvim concepts (`single_file_support`, `LspAttach`/`on_attach` callback, `workspace_required`, `before_init`) have direct pi-lens counterparts and should be adopted.
- **Reuse pattern:** every auxiliary LSP candidate below is a "warm LSP twin of an existing cold-CLI runner" — `cspell_ls` ↔ `spellcheck` runner, `vale_ls` ↔ `vale` runner, `biome` (LSP mode) ↔ `biome` runner, `ruff_lsp` ↔ `ruff` runner, `gitleaks_ls`/`zizmor` ↔ `gitleaks` runner, `buf_ls` ↔ no current buf runner, `markdown-oxide` ↔ no current markdown-LSP runner. Same shape, same `AUXILIARY_LSP_PROFILES` plumbing.

---

## 1. Current pi-lens LSP surface (asserted)

### Primary servers (35 active in `LSP_SERVERS` + 2 auxiliary = 37 in the array)

| pi-lens id | Name | FileKind | Notes |
|---|---|---|---|
| `typescript` | typescript-language-server | `jsts` | local-bin → `ensureTool`, `autoPropagateDiagnostics: true` |
| `deno` | Deno LSP | `jsts` | `createRootDetector(["deno.json","deno.jsonc"])` |
| `python` | pyright / basedpyright | `python` | `openFilesOnly: true` strategy |
| `python-jedi` | jedi-language-server | `python` | fallback when neither pyright nor basedpyright available |
| `go` | gopls | `go` | `goBinCandidates` includes `$GOPATH/bin`/`~/go/bin` (AGENTS.md #241) |
| `rust` | rust-analyzer | `rust` | `cargoBinCandidates` includes `$CARGO_HOME/bin`/`~/.cargo/bin` |
| `ruby` | ruby-lsp | `ruby` | `rubyBinCandidates` for mise/asdf/Windows Ruby* |
| `ruby-solargraph` | solargraph | `ruby` | alt for ruby |
| `php` | intelephense | `php` | `createInteractiveServer` |
| `csharp` | csharp-ls | `csharp` | auto-install via `dotnet tool install` |
| `omnisharp` | OmniSharp | `csharp` | alt for csharp |
| `fsharp` | FSAutocomplete | `fsharp` | auto-install via `dotnet tool install` |
| `java` | JDT Language Server | `java` | Lombok jar support via `createLombokJdtlsArgs` |
| `kotlin` | kotlin-language-server | `kotlin` | `createInteractiveServer` |
| `swift` | sourcekit-lsp | `swift` | toolchain-gated (`#241` deferred slice) |
| `dart` | dart-analysis-server | `dart` | toolchain-gated |
| `lua` | lua-language-server | `lua` | `createInteractiveServer` |
| `cpp` | clangd | `cxx` | toolchain-gated |
| `zig` | ZLS | `zig` | `ensureTool` |
| `haskell` | haskell-language-server | `haskell` | toolchain-gated (ghcup) |
| `elixir` | ElixirLS | `elixir` | `ensureTool` (github release) |
| `gleam` | Gleam LSP | `gleam` | auto-install via github tarball |
| `ocaml` | ocamllsp | `ocaml` | toolchain-gated (opam) |
| `clojure` | clojure-lsp | `clojure` | auto-install via github native binary |
| `terraform` | terraform-ls | `terraform` | `ensureTool` |
| `nix` | nixd | `nix` | toolchain-gated (still PATH-only) |
| `bash` | bash-language-server | `shell` | npm `ensureTool` |
| `docker` | docker-langserver | `docker` | npm `ensureTool` |
| `yaml` | yaml-language-server | `yaml` | npm `ensureTool` |
| `json` | vscode-json-languageserver | `json` | npm `ensureTool` |
| `html` | vscode-html-languageserver | `html` | npm `ensureTool` |
| `toml` | taplo | `toml` | `ensureTool` |
| `prisma` | prisma-language-server | `prisma` | npm `ensureTool` |
| `vue` | vue-language-server | `vue` | `ensureTool` |
| `svelte` | svelte-language-server | `svelte` | `ensureTool` |
| `css` | (registered, id `css` in `server.ts:1987`) | `css` | `ensureTool` (likely vscode-css-ls) |

(Asserted: `LSP_SERVERS` array is `clients/lsp/server.ts:2180-2222` and contains 37 entries (35 primary + 2 auxiliary). PowerShell is explicitly NOT included and the comment notes "coverage notice fires instead" — `clients/lsp/server.ts:2189`. The original reason given ("no viable LSP binary") should be revisited now that `powershell_es` exists in nvim-lspconfig, though a working alternative still has to be evaluated.)

**Orphan finding (worth a separate commit):** `ruby-solargraph` is fully defined as `RubySolargraphServer` at `clients/lsp/server.ts:1432` but is **not included** in the `LSP_SERVERS` array. Either the array line 2187 should read `RubyServer, RubySolargraphServer,` (giving users a Solargraph fallback like the existing `python`/`python-jedi` and `csharp`/`omnisharp` pairs), or `RubySolargraphServer` should be deleted. Verify before shipping — the AGENTS.md claims "37 LSP server IDs" which matches 35 primary + 2 aux; adding `ruby-solargraph` would make it 36 + 2 = 38.

### Auxiliary servers (2)

| pi-lens id | Name | Source match | Source of truth |
|---|---|---|---|
| `opengrep` | opengrep (Semgrep fork) | `opengrep\|semgrep` | `AUXILIARY_LSP_PROFILES[0]` — `clients/dispatch/auxiliary-lsp.ts:43-66` |
| `ast-grep` | ast-grep (napi) | `ast[-_]?grep` | `AUXILIARY_LSP_PROFILES[1]` — `clients/dispatch/auxiliary-lsp.ts:67-82` |

### Per-server diagnostic strategies (7 servers)

`clients/lsp/server-strategies.ts:25-92`:
- `typescript` — seedFirstPush, 1000ms aggregate
- `rust-analyzer` — no seedFirstPush, pull-retry, 3000ms aggregate
- `python` — seedFirstPush, 1500ms aggregate
- `python-jedi` — seedFirstPush, 3000ms aggregate (cold-start headroom)
- `eslint` — seedFirstPush, 2000ms aggregate
- `opengrep` — no seedFirstPush, 3500ms aggregate, `reopenOnResync: true`
- `ast-grep` — no seedFirstPush, 1800ms aggregate (post-#242)

### Per-server interface (`LSPServerInfo`)

`clients/lsp/server.ts:36-79` already has:
- `id`, `name`, `extensions`, `root: RootFunction`
- `role?: "language" | "auxiliary"`
- `availabilityKey?` for negative-TTL circuit-breaker
- `initializeTimeoutMs?`, `clientWaitTimeoutMs?` per-server
- `autoPropagateDiagnostics?` (cascade signal)
- `spawn(root, options?)` returning `{ process, initialization?, source? }`
- `autoInstall?` optional hook

**Missing** (vs nvim's defaults): no `single_file_support` flag, no `workspace_required` flag, no `init_options` per-server beyond the inline `initialization` blob, no `before_init`/`on_init` callback. See §3.

---

## 2. nvim-lspconfig lifecycle: what to learn, what to skip

nvim-lspconfig is a **UI shell** — its lifecycle assumes an interactive editor with autocmds. pi-lens is a **headless per-edit scanner** with one hot entry point (`LSPService.touchFile`). Most of nvim's machinery is editor-specific and would be cargo-culting. The valuable deltas are the per-server *capability flags* and *callbacks*, not the autocmd model.

### Lifecycle phases (side-by-side)

| Phase | nvim-lspconfig | pi-lens | Gap? |
|---|---|---|---|
| **Discovery** | `:LspInfo` (manual), `vim.lsp.config` registry, `vim.lsp.enable(name)` per-filetype | `getServersForFileWithConfig` + `customServers` from `.pi-lens/lsp.json` (`clients/lsp/config.ts:182-197`) | No gap — pi-lens is auto-resolve by extension + root marker |
| **Spawn** | `vim.lsp.start({cmd, root_dir, on_attach, before_init, on_init})` | `LSPService.touchFile` → `ensureClientForServer` → `LSPServerInfo.spawn` | **Gap:** no `before_init`/`on_attach` equivalent — see §3 |
| **Initialize** | `initialize` + `initialized` handshake with `capabilities` (the client describes itself) | same LSP protocol, `createLSPClient` (`clients/lsp/client.ts`); `advertisedCommands` allowlist built at `clients/lsp/client.ts:738` | No gap |
| **Document sync** | `textDocument/didOpen`, `didChange`, `didSave`, `didClose` | same, via `notify.open/change` (`client.ts`); `reopenOnResync` strategy for scanners that ignore `didChange` | No gap — `reopenOnResync` is a pi-lens *win* over nvim (nvim doesn't have this; you'd re-open manually) |
| **Diagnostics — push** | `textDocument/publishDiagnostics` notification | same; per-server `seedFirstPush`/`debounceMs` in `server-strategies.ts` | No gap — pi-lens is finer-grained |
| **Diagnostics — pull** | `workspace/diagnostic` request (server-pulled) | `clientRequestPullDiagnostics` returning a discriminated `PullDiagnosticsOutcome` (`found\|clean\|unavailable`) — #240 closed the silent-clean-as-clean bug | **Win:** pi-lens's affirmative-clean model is more honest than nvim's "empty result = clean" |
| **Commands** | `workspace/executeCommand` (server-initiated) | `client.executeCommand(command, args)` with `advertisedCommands` allowlist (`client.ts:1511-1533`) and dynamic registration | No gap |
| **Capabilities** | `vim.lsp.protocol.ClientCapabilities` | `getCapabilitySnapshots(filePath?)` returns per-client `operationSupport` (12 ops) + `workspaceDiagnosticsSupport` + `advertisedCommands` + `rawCapabilityKeys` (`client.ts:1396-1401`) | **Win:** pi-lens surfaces `rawCapabilityKeys` for nightly self-populating matrices (AGENTS.md). nvim doesn't ship this introspection. |
| **Detach** | `LspDetach` autocmd, `vim.lsp.stop_client` | `LSPShutdownOptions` with `fast: boolean` and `processExiting: boolean` (`client.ts`); `resetLSPService({ fast: true })` is the teardown primitive | No gap — pi-lens's fast/processExiting split is the right move for the TUI-shared-event-loop constraint |
| **Shutdown** | `client.shutdown` + `client.exit` | `LSPShutdownOptions`; process-tree-kill via `taskkill /F /T` on Windows (`launch.ts:752-768`); the `processExiting` flag is the libuv `UV_HANDLE_CLOSING` abort avoidance | No gap |

### What nvim does that pi-lens should adopt

| nvim concept | pi-lens status | Recommendation |
|---|---|---|
| `single_file_support` (server flag) | **Not modeled.** `LSPServerInfo` has no equivalent | **Add `singleFileSupport?: boolean`** to `LSPServerInfo`. `protols` (proto), `bqls` (SQL), `cmake-language-server`, `cmake_ls` are explicit single-file servers per nvim — they can't be the "project root" server. pi-lens's per-file `touchFile` already does single-file analysis, so the flag is mostly a documentation/hint for the install path (don't try to find a project root). |
| `workspace_required` | **Not modeled.** | **Add `workspaceRequired?: boolean`.** Inverse of the above — some servers (`biome` per nvim) explicitly say "needs a workspace, single-file mode is not supported." pi-lens's `root` detector returns `undefined` for single files, and the spawn short-circuits. Modeling this explicitly would let `LSPServerInfo` carry a clearer "why you might not be running" message. |
| `before_init` callback | **Not modeled.** Only the `initialization: Record<string, unknown>` blob is set per-server. | **Add `beforeInit?: (root: string) => Record<string, unknown> \| Promise<...>`** for cases where the initialization options depend on the project root (e.g. `harper-ls` config path resolution, `ts_query_ls` parser_install_directories). Currently pi-lens works around this by storing static initialization options in the `spawn` return value, but it can't read project files there. |
| `on_attach(client)` callback | **Not modeled.** | **Out of scope.** nvim uses it for `LspAttach` autocmds (keymaps, inlay hints). pi-lens is headless — no keymaps, no inlay. Skip. |
| `on_exit(code, signal, client)` | **Partial.** `launch.ts:_attachErrorHandler` (`launch.ts:230-275`) logs server exits to `sessionstart.log` but there's no per-server exit callback (e.g. to re-schedule a warming scan, or to bump a "LSP died" counter that the turn-end advisory surfaces). | **Add `onExit?: (code: number \| null, signal: NodeJS.Signals \| null) => void`** to `LSPServerInfo`. Lets a server (e.g. `snyk_ls`) register a one-shot restart, or let a dispatch runner know the warm LSP just died mid-dispatch and re-queue the diagnostic check. |
| `init_options` separate from `settings` | **Collapsed** into a single `initialization` blob in `spawn`'s return value. | **Keep collapsed** — pi-lens has no UI for separating them; the JSON-RPC payload is what it is. Not a real gap. |
| Per-filetype activation gates (e.g. `vim.lsp.enable('biome', {filetype='typescript'})`) | **Partial.** `extensions: readonly string[]` on `LSPServerInfo` drives `getServersForFile`; auxiliaries match by `role: 'auxiliary'` + `enabledAuxiliaryLspServerIds` | **Adopt `enableForFiletypes` only if a future auxiliary needs to be per-language (e.g. `harper_ls` for English docs only).** For now, the kill-switch + `enabledByDefault` flag pattern in `AUXILIARY_LSP_PROFILES` (`auxiliary-lsp.ts:34-37`) is the right granularity. |
| `single_file_support: true` servers (no project needed) | **Not surfaced.** | **Add `singleFileSupport?: boolean` + use it in `getServersForFile`** so single-file buffers (e.g. `/tmp/scratch.proto` in pi's interactive mode) still get analyzed. Currently pi-lens's `root` function returns `undefined` and the LSP silently skips — a `singleFileSupport` flag would let the spawn proceed with the file dir as the root. |

### What nvim does that pi-lens should NOT adopt

- `LspAttach` / `LspDetach` autocmds — interactive-only.
- `vim.lsp.inlay_hint` / `vim.lsp.document_highlight` — UI-only.
- `vim.lsp.buf.format` / `code action preview` — pi-lens has its own pipeline (`runFormatPhase`, `applyWorkspaceEdit` in `clients/lsp/edits.ts`).
- `:LspInfo`, `:LspLog`, `:LspRestart` — pi-lens surfaces LSP state via `lens_diagnostics`, `lens_health`, `/lens-health` command, and the `~/.pi-lens/sessionstart.log`/`cascade.log` files.
- Per-buffer `vim.b:some_lsp_client` indirection — pi-lens is per-file-by-content, not per-buffer.

### The structural wins pi-lens has over nvim

- **Affirmative-clean diagnostic model** (`PullDiagnosticsOutcome = found|clean|unavailable`, AGENTS.md #240). nvim treats "no diagnostics" as clean without the same verifiability.
- **Per-server strategy table** (`SERVER_DIAGNOSTIC_STRATEGIES`) codifies server behavior — nvim has no equivalent and re-derives it via per-buffer "LSP finished" heuristics.
- **Auxiliary profile pattern** (`AUXILIARY_LSP_PROFILES`) is a clean separation of "server lifecycle" from "diagnostic policy" — nvim attaches every server as primary and has no concept of "this server is cross-cutting, it must not block, and it's only blocking when the repo supplies curated rules."
- **Content-hash last-known cache** (`lastKnownDiagnostics` + `lastKnownContentHash` in `clients/lsp/index.ts:313-320`) is the right answer to "the LSP just exited, what do we show?" — nvim's `vim.diagnostic.get` after a restart is empty, not the last known.
- **Fast / processExiting shutdown split** (`LSPShutdownOptions` in `client.ts`) — pi-lens is honest about the libuv `UV_HANDLE_CLOSING` abort trap that bit nvim too (`client.ts` documents the rationale in the `processExiting` docstring).

---

## 3. Recommendations — primary LSPs (net-new)

I extracted the nvim-lspconfig corpus (`configs.md` 401 entries) and classified by `filetypes` overlap with pi-lens's `LANGUAGE_EXTENSIONS` (`clients/lsp/language.ts:9-184`) and `FileKind` map. The result:

- **135 nvim servers overlap a language pi-lens already covers** (most are alternates to a server pi-lens already has — e.g. `quick_lint_js`, `denols`, `tflint`+`tofu_ls`, `pyright`+`pylyzer`+`basedpyright` for Python).
- **256 nvim servers are net-new primary candidates** (languages pi-lens doesn't currently support at all).
- **10 nvim servers are auxiliary-pattern candidates** (cross-cutting, lint/spell/security).

Net-new primary recommendations ranked by **agent-edit-traffic × ease-of-install**:

### P0 — high-traffic languages pi-lens DOESN'T have an LSP for

| Recommendation | nvim id | Install path | FileKind to add | Why now |
|---|---|---|---|---|
| **`buf_ls`** | `buf_ls` | `brew install buf` / `go install` | `proto` | protobuf files are common in agent work; pi-lens has `prisma` (single-file `prisma-validate` runner) but no `proto` LSP. **Bonus: also fits the auxiliary pattern** for buf-style cross-file schema validation — see §4. |
| **`sqls` / `postgres_lsp` / `sqlls`** | `sqls`, `postgres_lsp`, `sqlls` | `go install` | `sql` (currently `lspCapable: false`, `clients/language-policy.ts:79-83`; runner is `sqlfluff` only) | `sql` is in pi-lens's FileKind map (`clients/file-kinds.ts`) with `sqlfluff` as the sole primary runner (`language-policy.ts:135-138`). A warm SQL LSP would give live `intellisense`-class findings on `SELECT`/`FROM` resolution that `sqlfluff` (lint-only) doesn't. |
| **`graphql` (graphql-lsp)** | `graphql` | `npm install -g @graphql-tools/graphql-language-service` | new `graphql` FileKind | `.graphql`/`.gql` files are in `LANGUAGE_EXTENSIONS` (`language.ts:144-145`) but not in `FileKind` and not in any dispatch group. Silent no-op. |
| **`marksman` / `markdown-oxide` / `prosemd_lsp`** | `marksman`, `markdown_oxide`, `prosemd_lsp` | single-binary download | `markdown` (currently `lspCapable: false`, runner is `spellcheck`+`vale`) | Markdown is the most-edited file kind in pi sessions and pi-lens has zero LSP for it (no `lsp` in `PRIMARY_DISPATCH_GROUPS.markdown` — `language-policy.ts:108-112`). `marksman` gives cross-file reference checks (broken anchors, missing docs) that the cold spellcheck/vale runners miss. |

Asserted: `markdown` group in `clients/language-policy.ts:108-112` has `runnerIds: ["spellcheck", "vale"]` with `lspCapable: false` on the policy line. The `lsp` runner's `appliesTo` list (`clients/dispatch/runners/lsp.ts:72-89`) *does* include `"markdown"`, but `getPrimaryDispatchGroup` (`clients/language-policy.ts:194-208`) only adds the runner to the group if the base group lists it — and the markdown base group doesn't. So `lsp` is silently skipped for `.md`/`.mdx` files. Adding a `markdown-lsp` server + flipping `lspCapable` to `true` + including `"lsp"` in the dispatch group would unblock the `lsp` runner and let the existing `with-auxiliary` collection pick up a markdown-auxiliary automatically.

### P1 — gaps in languages pi-lens partially covers

| Recommendation | nvim id | Why | FileKind |
|---|---|---|---|
| **`ruff_lsp` (warm ruff)** | `ruff_lsp` (`pip install ruff-lsp`) | pi-lens has a `ruff` runner (`clients/dispatch/runners/ruff.ts`) and a `ruffFormatter` (`formatters.ts:461`). The LSP is the warm equivalent — same rules, instant feedback, no cold CLI spawn. | `python` (add to `PRIMARY_DISPATCH_GROUPS.python` as alt) |
| **`rubocop --lsp`** | `rubocop` | pi-lens already has `rubocop` runner + formatter. The LSP form is a 1-line `args: ["--lsp"]` swap from the existing Ruby server entries. | `ruby` |
| **`solargraph`** | `solargraph` | Already registered as `ruby-solargraph` in `LSP_SERVERS` (`server.ts:1433`). Verify install path works. | `ruby` |
| **`tflint`** | `tflint` | pi-lens has a `tflint` runner (`runners/tflint.ts`) but `terraform` group only has `["lsp", "tflint"]` with `lsp` resolving to `terraform-ls` (`server.ts:1711`). Adding `tflint --language-server` as a parallel warm server is the auxiliary pattern. | `terraform` (auxiliary) |
| **`tofu_ls`** | `tofu_ls` | OpenTofu fork of terraform-ls. Same `LANGUAGE_EXTENSIONS["terraform"]` works. | `terraform` (alt to terraform-ls) |
| **`psalm` / `phpactor` / `phpantom_lsp`** | `psalm`, `phpactor`, `phpantom_lsp` | pi-lens has `intelephense` + `php-lint` + `phpstan` runner. These give better type inference than `intelephense` alone. | `php` |
| **`verible` / `svls` / `svlangserver` / `hdl_checker`** | `verible`, `svls`, `svlangserver`, `hdl_checker` | SystemVerilog/Verilog/VHDL — hardware design is a real pi-lens user (Chisel, SpinalHDL, nMigen). pi-lens has zero `systemverilog`/`verilog`/`vhdl` in `LANGUAGE_EXTENSIONS` (only `vhdl`/`v`/`sv` `language.ts:122-126`, no FileKind, no runner). | new `systemverilog`, `verilog`, `vhdl` FileKinds |

### P2 — niche but cheap (lots of traffic in their own ecosystems)

| Recommendation | nvim id | Install | FileKind |
|---|---|---|---|
| **`nim_langserver`** | `nim_langserver` | `nimble install` | new `nim` |
| **`crystalline`** | `crystalline` | `shards install` | new `crystal` |
| **`metals`** | `metals` | `coursier install` | new `scala` (LANGUAGE_EXTENSIONS has `scala`/`sc` at `language.ts:108-109` but no FileKind, no runner) |
| **`julials`** | `julials` | julia pkg + `LanguageServer.jl` | new `julia` |
| **`elp` (Erlang)** | `elp` | rebar3 | new `erlang` |
| **`perlls` / `perlnavigator`** | `perlls`, `perlnavigator` | cpan | new `perl` |
| **`elmls`** | `elmls` | npm | new `elm` |
| **`r_language_server`** | `r_language_server` | R package | new `r` |
| **`fortls`** | `fortls` | pip | new `fortran` |
| **`rescriptls`** | `rescriptls` | npm | new `rescript` |
| **`gdscript`** | `gdscript` | godot binary | new `gdscript` |
| **`pbls` / `protols`** | `pbls`, `protols` | single-binary | new `proto` (overlap with `buf_ls` above — pick one) |
| **`cmake-language-server` / `neocmakelsp`** | `cmake`, `neocmake` | pip / npm | `cmake` (already in `FileKind`/`language-policy.ts:60-62` with `runnerIds: ["lsp"]` only — no actual LSP server registered) |
| **`biome` (LSP mode)** | `biome` | `npm install -g @biomejs/biome` | n/a as primary — fits auxiliary better (§4) |
| **`verible`** | `verible` | single-binary | `systemverilog` (as P1) |
| **`bashls`** | `bashls` | npm | `shell` (pi-lens has `bash-language-server`; `bashls` is a polyglot alt that covers ksh/zsh better) |
| **`yaml` (LSP for `yaml.ansible`, `yaml.gitlab`, `yaml.docker-compose`, `yaml.helm-values`)** | various | various | pi-lens has `yaml-language-server` for plain YAML; the per-vendor servers give lint rules that the generic server can't. Auxiliary pattern. |
| **`astro`** | `astro` | npm | new `astro` (LANGUAGE_EXTENSIONS has `astro` at `language.ts:21` but no FileKind) |
| **`tinymist` / `typst_lsp`** | `tinymist`, `typst_lsp` | single-binary | new `typst` (LANGUAGE_EXTENSIONS has `typ`/`typc` at `language.ts:181-183` but no FileKind) |
| **`ds_pinyin_lsp` / `mdx_analyzer`** | `ds_pinyin_lsp`, `mdx_analyzer` | npm | `markdown` alt |

### P3 — the long long tail

`ada_ls`, `agda_ls`, `aiken`, `alloy_ls`, `ansiblels`, `apex_ls`, `arduino_language_server`, `asm_lsp`, `atlas`, `atopile`, `autohotkey_lsp`, `autotools_ls`, `awk_ls`, `ballerina`, `basics_ls`, `bazelrc_lsp`, `beancount`, `bicep`, `bitbake_language_server`, `blueprint_ls`, `bright_script`, `brioche`, `bsl_ls`, `buck2`, `buddy_ls`, `bzl`, `c3_lsp`, `cairo_ls`, `cds_lsp`, `cir_lsp_server`, `circom-lsp`, `clarinet`, `cobol_ls`, `coffeesense`, `contextive`, `copilot`, `coq_lsp`, `cqlls`, `csharp_ls` (alternate to csharp-ls), `cucumber_language_server`, `cue`, `custom_elements_ls`, `cypher_ls`, `daedalus_ls`, `dafny`, `dagger`, `dhall_lsp_server`, `diagnosticls`, `digestif`, `docker_compose_language_service`, `dolmenls`, `dotls`, `dts_lsp`, `earthlyls`, `ecsact`, `elmls`, `erg_language_server`, `esbonio`, `facility_language_server`, `fennel_language_server`, `fennel_ls`, `fish_lsp`, `flux_lsp`, `foam_ls`, `fortitude`, `fstar`, `futhark_lsp`, `gdshader_lsp`, `ghdl_ls`, `ginko_ls`, `gitlab_ci_ls`, `glasgow`, `glsl_analyzer`, `glslls`, `gn_language_server`, `gnls`, `gradle_ls`, `groovyls`, `guile_ls`, `hare_lsp`, `helm_ls`, `hlasm`, `hoon_ls`, `hylo_ls`, `hyprls`, `idris2_lsp`, `janet_lsp`, `jinja_lsp`, `jqls`, `jsonnet_ls`, `just`, `kcl`, `koka`, `kulala_ls`, `lean3ls`, `lelwel_ls`, `lemminx`, `lsp_ai`, `luau_lsp`, `m68k`, `matlab_ls`, `mesonlsp`, `metals`, `microcad_lsp`, `millet`, `mint`, `mlir_lsp_server`, `mlir_pdll_lsp_server`, `mm0_ls`, `mojo`, `motoko_lsp`, `move_analyzer`, `msbuild_project_tools_server`, `muon`, `mutt_ls`, `nelua_lsp`, `neocmake`, `nextflow_ls`, `nginx_language_server`, `nickel_ls`, `nimls`, `nomad_lsp`, `ntt`, `nushell`, `ols`, `omnisharp`, `opencl_ls`, `openscad_ls`, `openscad_lsp`, `oso`, `pact_ls`, `panache`, `pasls`, `perlls`, `perlpls`, `pest_ls`, `pico8_ls`, `please`, `pli`, `pony_language_server`, `pony_lsp`, `poryscript_pls`, `postgres_lsp`, `powershell_es`, `prolog_ls`, `prosemd_lsp`, `protols`, `pug`, `puppet`, `purescriptls`, `qmlls`, `racket_langserver`, `raku_navigator`, `reason_ls`, `regal`, `regols`, `remark_ls`, `robotcode`, `robotframework_ls`, `roc_ls`, `roslyn_ls`, `rpmspec`, `rumdl`, `rune_languageserver`, `salt_ls`, `scheme_langserver`, `scry`, `serve_d`, `shopify_theme_ls`, `sixtyfps`, `slang_server`, `slangd`, `slint_lsp`, `smarty_ls`, `smithy_ls`, `snakeskin_ls`, `solang`, `solc`, `solidity`, `solidity_ls`, `solidity_ls_nomicfoundation`, `spade_ls`, `spyglassmc_language_server`, `sqlls`, `sqruff`, `stan_ls`, `starlark_rust`, `starpls`, `svlangserver`, `swift_mesonls`, `systemd_ls`, `systemd_lsp`, `tabby_ml`, `tblgen_lsp_server`, `tclsp`, `teal_ls`, `templ`, `termux_language_server`, `texlab`, `textlsp`, `theme_check`, `thriftls`, `tilt_ls`, `tinymist`, `trunk`, `ts_query_ls`, `tsp_server`, `turtle_ls`, `twiggy_language_server`, `typst_lsp`, `uiua`, `unison`, `uvls`, `v_analyzer`, `vacuum`, `vala_ls`, `vectorcode_server`, `verible`, `veridian`, `veryl_ls`, `vespa_ls`, `vhdl_ls`, `vimdoc_ls`, `vimls`, `visualforce_ls`, `vls`, `vscoqtop`, `vsrocq`, `vtsls`, `wasm_language_tools`, `wgsl_analyzer`, `yang_lsp`, `yls`, `ziggy`, `ziggy_schema`, `zk`.

**Recommendation: don't add these speculatively.** Each new `LSPServerInfo` is a maintenance surface (install strategy, root detector, capability strategy entry, test coverage). Wait for user demand (GitHub issues) or dogfood a few (Scala, Nix, Clojure, Erlang, Fortran have the highest current user-traffic signals in pi-lens issues).

---

## 4. Recommendations — auxiliary LSPs (high-leverage, low cost)

This is the **biggest opportunity** and the most pi-lens-idiomatic. The pattern is already proven: `opengrep` (security scanner, ~8s cold CLI per file → ~1.3s warm LSP) and `ast-grep` (curated-rules scanner, similar story). Both are implemented as:

1. One `LSPServerInfo` with `role: "auxiliary"` (`server.ts:2060-2140`).
2. One `AUXILIARY_LSP_PROFILES` entry (`auxiliary-lsp.ts:43-82`).
3. One `SERVER_DIAGNOSTIC_STRATEGIES` entry if the scan timing is non-default (`server-strategies.ts`).

**Reuse pattern — "warm LSP twin of an existing cold-CLI runner":**

| Auxiliary server | Existing cold runner | Install | Gain |
|---|---|---|---|
| **`cspell_ls`** | `spellcheck` runner (`runners/spellcheck.ts`) | npm | Warm spellcheck across `.md`/`.mdx`/`.txt` (and code strings). nvim `cspell_ls` has explicit `filetypes: {}` — it activates on file open regardless of filetype, which is exactly the auxiliary pattern. |
| **`vale_ls`** | `vale` runner (`runners/vale.ts`) | cargo/bin | Warm prose lint, `root_markers: {".vale.ini"}`. Filetypes: `asciidoc, markdown, text, tex, rst, html, xml` — broader than the runner. |
| **`harper_ls`** | none (Harper is the gap) | cargo/bin | Grammar checker. Filetypes: 30+ (essentially auxiliary — every text-bearing file). Cross-language. |
| **`ltex` / `ltex_plus`** | `vale` (partial overlap) | java jar | Bilingual grammar (English+). `ltex_plus` is the maintained fork. |
| **`gitleaks_ls`** (doesn't exist as LSP — **N/A**) | `gitleaks` is in the installer registry (`installer/index.ts:695`) | n/a | Not an LSP — skip. But **zizmor** (below) is. |
| **`zizmor`** | none (GitHub Actions security — gap) | cargo/bin | `filetypes: { "yaml" }`, `cmd: { "zizmor", "--lsp" }`. Cross-cutting security scanner on workflow YAML. **This is the same shape as opengrep.** The blockingAllowed gate should follow the same sgconfig-style "only block on curated rules" pattern. |
| **`buf_ls`** | none (buf is a protobuf/CI tool) | brew/go | Cross-file proto schema validation. Auxiliary because it spans `proto` files and gives a project-wide lint, not per-file. |
| **`biome` (LSP mode)** | `biome` runner (`runners/biome-check.ts`, `biome.ts`) + `biomeFormatter` (`formatters.ts:334`) | npm | nvim `biome` lists `filetypes: { "astro", "css", "graphql", "html", "javascript", "javascriptreact", "json", "jsonc", "svelte", "typescript", "typescriptreact", "vue" }` — **12 filetypes**. The current `biome` runner is per-project-config-detect; the LSP attaches per-file and gives consistent findings even on projects without `biome.json`. **Conflict:** `biome` is `workspace_required: true` per nvim — can't run on a single loose file. Auxiliary fits because warm rules compile once per session. |
| **`eslint` LSP (already pi-lens-supported)** | `eslint` runner (`runners/eslint.ts`) | npm | Already has `SERVER_DIAGNOSTIC_STRATEGIES["eslint"]`. The runner is cold CLI; the LSP form (when wired through `typescript-language-server` or `vscode-eslint-language-server`) is the warm equivalent. **Verify the existing typescript-language-server wired-in eslint diagnostics actually come from eslint the language server.** If yes, the "warm eslint" story is done. |
| **`oxlint` LSP (oxc)** | `oxlint` runner (`runners/oxlint.ts`) | npm | `oxc` is a fast Rust-based linter for JS/TS. nvim doesn't ship an `oxc_ls` entry in the configs.md I read, but [`oxc-project/oxc-intellij`](https://github.com/oxc-project/oxc) and the standalone [`oxlint --lsp`](https://github.com/oxc-project/oxc) are emerging. Worth tracking. |
| **`ts_query_ls`** | none | cargo/bin | tree-sitter query LSP. The `ts_query_ls` has a custom `init_options` with `parser_install_directories` and `parser_aliases` — a real use case for the **`beforeInit` callback** recommended in §3. |
| **`markdown_oxide` / `marksman` / `prosemd_lsp`** | none (markdown LSP gap) | binary | Auxiliary pattern fits well even though they're "per-file" — the diagnostic quality is much higher than spellcheck/vale. Could be primary OR auxiliary. |

### How to add one (asserted against the existing pattern)

The opengrep registration is the template. Reading `auxiliary-lsp.ts:43-66` + `server.ts:2071-2141`:

```ts
// 1. clients/lsp/server.ts: add the server (id="cspell-lsp", role:"auxiliary", singleFileSupport:true)
// 2. clients/dispatch/auxiliary-lsp.ts: add one AUXILIARY_LSP_PROFILES entry
// 3. clients/lsp/server-strategies.ts: add one SERVER_DIAGNOSTIC_STRATEGIES["cspell-lsp"]
// 4. clients/installer/index.ts: add a tool id + install strategy
// 5. tests/clients/dispatch/auxiliary-lsp.test.ts: add profile routing test
// 6. tests/clients/installer/tool-registry-consistency.test.ts: registry sync check (auto)
```

Each auxiliary is **~3 files + 1 test**. The total surface to add all 6 P0 auxiliary candidates is ~24 file changes.

### Auxiliary gating — the hard problem

`auxiliary-lsp.ts:50-65` codifies the right policy for opengrep:
```ts
allowBlocking: (cwd) => Boolean(findLocalOpengrepConfig(cwd)),
```
**Block only on curated rules** — the author's deliberate severity. This is the pattern every new auxiliary should follow. The reasoning (per the in-file comment) is: the Community audit set is uniformly `ERROR/LOW-confidence`, blocking on it would be a firehose.

This is a non-obvious policy choice and it generalizes:
- **`cspell_ls`** — block on curated `cspell.json` (project's own wordlist additions), advisory on bare `cspell_ls` defaults.
- **`zizmor`** — block on a `.zizmor.yaml` in repo, advisory on bare `zizmor --lsp`.
- **`buf_ls`** — block on a `buf.yaml` lint section, advisory on bare workspace lint.
- **`biome` (LSP)** — block on a `biome.json` with explicit `linter.rules` (project's policy), advisory on default config.

Each new auxiliary should codify its `allowBlocking` predicate the same way and add a unit test (`tests/clients/dispatch/auxiliary-lsp.test.ts`-style).

---

## 5. Opportunities and reuse patterns

### Reuse pattern 1: `singleFileSupport` flag

nvim's `single_file_support: true` (e.g. `protols`, `bqls`, `cmake-language-server`) tells nvim "don't bother walking up to a project root — operate on the file." pi-lens's `root: RootFunction` returns `undefined` for single files and the spawn short-circuits. Adding `singleFileSupport?: boolean` to `LSPServerInfo` would let these servers work on `/tmp/scratch.proto` and the turn-end diagnostic for an isolated file. The current behavior is a **silent no-op** — no coverage notice because there's no `lsp` in the dispatch group, no `runnerIds` to check.

**Files to touch:** `clients/lsp/server.ts` (add field), `clients/lsp/index.ts` (`ensureClientForServer` short-circuit logic at `index.ts:624-...`).

### Reuse pattern 2: `beforeInit(root) -> Record<string, unknown>`

nvim's `before_init` callback runs after `cmd` is resolved but before the `initialize` handshake. It's used for things like `harper-ls` config path resolution, `ts_query_ls` parser directory computation, and `metals` user-home resolution. pi-lens today works around this by inlining static `initialization` in the `spawn` return value (`server.ts:1123-1134` for `typescript` is a good example — it sets `tsserver: { path }`).

A real `beforeInit` would unlock:
- `harper-ls` reading `.harper-config.yaml` to populate initialization options.
- `metals` reading `coursier` cache location.
- `ts_query_ls` setting `parser_install_directories` from a project-relative path.
- `biome` (LSP) reading `biome.json` to compute its effective config.

**Files to touch:** `LSPServerInfo` interface (`server.ts:36-79`), `LSPService.ensureClientForServer` (`index.ts:624-...`).

### Reuse pattern 3: `onExit` callback → dispatch re-schedule

When a warm LSP dies mid-dispatch, pi-lens logs to `sessionstart.log` (`launch.ts:240-265`) but doesn't tell the dispatch pipeline. The next `touchFile` will re-spawn, but the **current turn's** diagnostic for the file the LSP just died on is a `serverFailed: true` advisory (`runners/lsp.ts:163-181`). An `onExit` callback on `LSPServerInfo` would let the dispatch runner schedule a re-warm and a re-touch on the next `setImmediate`, recovering the diagnostic in the same turn.

**Files to touch:** `LSPServerInfo` interface, `createLSPClient` (call the callback on `proc.exit`), `runners/lsp.ts` (consume the rescheduled result).

### Reuse pattern 4: `workspaceRequired` flag

Inverse of `singleFileSupport`. Lets pi-lens distinguish "this server can't run" (`workspaceRequired: true` + no project found → coverage notice) from "this server should run on this file" (default). Currently pi-lens's `coverageNotice` (`dispatcher.ts:387-457`) only fires when ALL primary runners skip — it doesn't surface "I know the LSP for this file is a workspace-required one and you're not in a workspace." Modeling this explicitly would let the `lsp` runner emit a clearer "this file isn't checked because no project root was found" message rather than a silent skip.

**Files to touch:** `LSPServerInfo`, `runners/lsp.ts` early-return.

### Reuse pattern 5: Pull diagnostics as a per-server default

nvim-lspconfig treats `workspace/diagnostic` as opt-in. pi-lens's `clientRequestPullDiagnostics` already has the right shape (discriminated `found|clean|unavailable`, AGENTS.md #240). The current `SERVER_DIAGNOSTIC_STRATEGIES` says `pullRetryBudgetMs: 0` for typescript, python, eslint, opengrep, ast-grep. For Tier-3 silent-push servers (typescript), retrying a pull on a dirty file sometimes surfaces diagnostics the LSP didn't push. A `pullOnEmptyPush: boolean` flag in the strategy would let a server like `typescript-language-server` opt into a single pull after a clean push.

**Files to touch:** `DiagnosticStrategy` interface (`server-strategies.ts:7-44`), `clientWaitForDiagnostics` consumer.

### Reuse pattern 6: `advertisedCommands` is the new `executeCommandProvider.commands`

Already shipped. Worth noting: pi-lens's `client.ts:411-425` tracks `advertisedCommands` (the runtime allowlist) separately from the initialize-time `executeCommandProvider.commands`, and `client.ts:738` merges dynamic registrations into the same set. **This is more complete than nvim's `vim.lsp.config(name).commands`, which doesn't track dynamic registrations.** Worth surfacing in `getCapabilitySnapshots` (already done) and in `docs/servercapabilities.md` (already done per AGENTS.md).

### Reuse pattern 7: `extraFiletypes` for the auxiliary pattern

The nvim `cspell_ls` entry has `filetypes: {}` (empty) — it activates on any open file. This is the auxiliary pattern in disguise. pi-lens's `enabledAuxiliaryLspServerIds` already gates on `enabledByDefault` + kill-switch; adding a `filetypes?: readonly string[]` (or `extraLanguages?: Set<string>`) to `AUXILIARY_LSP_PROFILES` would let an auxiliary opt into a language subset. e.g. `harper_ls` could be enabled only for `markdown`/`text` filetype to avoid running it on `.json`/`.lock` files.

**Files to touch:** `auxiliary-lsp.ts:33-39` interface, `runners/lsp.ts:113-117` filter.

### Reuse pattern 8: Cold-start cost amortization across the team

nvim-lspconfig has no warm-coordination, but the **co-warming** problem is real. When a session touches `python`, `python-jedi`, `python` is the primary and `opengrep` is auxiliary — both spawn. The "warmup" pattern in pi-lens's `runtime-session.ts` already runs them as deferred background tasks. **The opportunity:** share the file open between the primary and auxiliary so the first cold `didOpen` of a file is amortized across all servers that would scan it.

This is currently handled by `clientScope: "with-auxiliary"` + `Promise.all` in `lspService.touchFile` (`index.ts:893-...`). The deferred-warmup path is at `runtime-session.ts`. Adding the auxiliary list to the warmup task (today it only warms primary servers — `auxiliaryServerIds` is empty during warmup) would let the first edit after session_start hit a fully-warm stack instead of a cold primary + cold aux.

**Files to touch:** `clients/runtime-session.ts` (warmup task that calls `touchFile`), `lspService.warmup` (or equivalent).

---

## 6. Concrete next steps (ordered, sized)

| # | Action | Files | Effort | Risk |
|---|---|---|---|---|
| 1 | Add `singleFileSupport?: boolean` + `workspaceRequired?: boolean` + `beforeInit?: (root) => Promise<Record<string, unknown>>` + `onExit?: (code, signal) => void` to `LSPServerInfo` | `clients/lsp/server.ts` (interface), `clients/lsp/index.ts` (consume in `ensureClientForServer` + `createLSPClient`) | M | Low — additive, no existing behavior changes |
| 2 | Wire up `cspell_ls` as auxiliary (warm twin of `spellcheck` runner) | `server.ts`, `auxiliary-lsp.ts`, `server-strategies.ts`, `installer/index.ts`, `tests/clients/dispatch/auxiliary-lsp.test.ts` | S | Low — pattern proven by opengrep |
| 3 | Wire up `vale_ls` as auxiliary (warm twin of `vale` runner) | same as #2 | S | Low |
| 4 | Wire up `zizmor` as auxiliary (GitHub Actions security — new coverage) | same as #2 | S | Low |
| 5 | Wire up `biome` LSP mode as auxiliary for non-biomercfg projects (auxiliary that picks up the slack when `biome.json` is missing) | same as #2 | S | Low — must NOT conflict with existing `biome` runner when `biome.json` is present |
| 6 | Add `marksman` (or `markdown-oxide`) as primary `markdown` LSP — unblocks `lsp` runner for markdown | `server.ts`, `language-policy.ts:108-112` (add `lsp` to runnerIds, set `lspCapable: true`), `LSP_SERVERS` array, `installer/index.ts` | M | Medium — markdown is the highest-traffic file kind, needs careful strategy entry |
| 7 | Add `buf_ls` (or `protols`) as primary for `proto` files | new `proto` FileKind, `server.ts`, `language.ts` (already has `proto` at `language.ts:152`), `language-policy.ts` (new group), `installer/index.ts` | M | Low |
| 8 | Add `sqls` / `postgres_lsp` as primary for `sql` (turn `lspCapable: false` to `true` in `language-policy.ts:79-83`) | `language-policy.ts`, `server.ts`, `installer/index.ts` | M | Low |
| 9 | Add `graphql` (graphql-lsp) as primary for new `graphql` FileKind | new FileKind, `language.ts` (already has `graphql` at `language.ts:144`), `language-policy.ts` (new group) | M | Low |
| 10 | Migrate `typescript-language-server`'s inline eslint integration into a proper `eslint` server profile (if not already) and surface as auxiliary — verify with `rawCapabilityKeys` in `getCapabilitySnapshots` | depends on audit | M | Medium |
| 11 | Share first-touch `didOpen` between primary + auxiliary during warmup (Reuse Pattern 8) | `runtime-session.ts` warmup path, `lspService.warmup` | M | Medium — has to keep the per-server diagnostic strategy intact |
| 12 | Per-server `pullOnEmptyPush: boolean` strategy flag (Reuse Pattern 5) for Tier-3 silent-push servers | `server-strategies.ts` interface, `clientWaitForDiagnostics` consumer | S | Low |

**Effort legend:** S = <1h, M = 1–4h, L = >4h. None of these are L individually.

### What NOT to do (defended non-decisions)

- **Don't add 256 net-new primary servers speculatively.** Each one is a maintenance tax. Drive the long tail from user demand.
- **Don't migrate to nvim's autocmd model.** pi-lens is a per-edit scanner, not an editor. The `touchFile` contract is correct.
- **Don't add `on_attach` callback.** Editor-only.
- **Don't expose a "global kill-switch" for an entire auxiliary class** (e.g. "disable all security scanners"). The per-server kill-switch in `AUXILIARY_LSP_PROFILES` (`auxiliary-lsp.ts:34-37`) is the right granularity.

---

## 7. Self-audit (asserted against the codebase)

Every recommendation above was verified against the live codebase at scan time. Specific assertions:

- **Current LSP count: 35 primary in array + 2 auxiliary = 37 active. Plus 1 orphan (`ruby-solargraph` defined at `server.ts:1432` but not in the array).**
  Asserted: `LSP_SERVERS` array (`clients/lsp/server.ts:2180-2222`) lists 37 entries; subtract the 2 auxiliaries and you get 35 primary. The `id:` scan in `server.ts` returns 38 unique IDs because `ruby-solargraph` is defined but not added to the array.
- **Auxiliary gating pattern:** `AUXILIARY_LSP_PROFILES` at `clients/dispatch/auxiliary-lsp.ts:43-82`, two entries (opengrep, ast-grep). `findAuxiliaryProfileForSource` is the routing function (`auxiliary-lsp.ts:104-110`).
- **Auxiliary integration with dispatch:** `runners/lsp.ts:111-118` collects auxiliary IDs via `enabledAuxiliaryLspServerIds`, passes them in `touchFile` options. `lsp.ts:181-203` re-tags diagnostics with the auxiliary tool + semantic policy.
- **Strategy table:** 7 entries in `SERVER_DIAGNOSTIC_STRATEGIES` (`server-strategies.ts:25-92`). `getStrategy(serverId)` falls back to `DEFAULT_STRATEGY` (line 100).
- **Per-server LSP server features:** `LSPServerInfo` interface at `clients/lsp/server.ts:36-79`. Has `role?`, `availabilityKey?`, `initializeTimeoutMs?`, `clientWaitTimeoutMs?`, `autoPropagateDiagnostics?`. Missing: `singleFileSupport`, `workspaceRequired`, `beforeInit`, `onExit` (recommended above).
- **Coverage notice:** `buildCoverageNotice` in `clients/dispatch/dispatcher.ts:387-457`. Fires when ALL primary runners skip and no fallback linter ran. Includes `STRUCTURAL_RUNNERS` carve-out (line 434-442) so tree-sitter/ast-grep don't suppress it.
- **Tool-registry-consistency guard:** `tests/clients/installer/tool-registry-consistency.test.ts` (mentioned in AGENTS.md, not opened in this scan — verifying by re-running after #1 is implemented is sufficient).
- **Auxiliary profile test:** `tests/clients/dispatch/auxiliary-lsp.test.ts` is the place to add a `cspell_ls`/`vale_ls`/`zizmor` test (asserted by name pattern; not opened in this scan).
- **PowerShell is explicitly NOT included** — `clients/lsp/server.ts:2189` comment: `// PowerShellServer — not included; no viable LSP binary, coverage notice fires instead`. The `psscriptanalyzer` runner (`runners/psscriptanalyzer.ts`) is the cold-CLI fallback. This is the only FileKind in `LANGUAGE_POLICY` with `lspCapable: true` but no `LSP_SERVERS` entry (`language-policy.ts:120-123`).
- **`markdown` is `lspCapable: false`** (`language-policy.ts:88`) and the dispatch group is `["spellcheck", "vale"]` with `mode: "fallback"` (`language-policy.ts:108-112`). This is the most-visible gap in the primary dispatch table.
- **`sql` is `lspCapable: false`** (`language-policy.ts:79`) and the dispatch group is `["sqlfluff"]` only (`language-policy.ts:135-138`).
- **Installer registry has 47 tool IDs** (`clients/installer/index.ts`, ID scan). Adding an auxiliary requires one new entry + a registry-consistency test pass.
- **`biome` is currently a runner** (`runners/biome.ts`, `runners/biome-check.ts`) AND a formatter (`formatters.ts:334`). The LSP form would be the third surface — a "warm biome for projects that don't have a `biome.json`" auxiliary.
- **`rawCapabilityKeys` and `advertisedCommands` are server-capability inventory wins** over nvim (`client.ts:411-425`, `index.ts:1563-1600`).

### Confidence on each P0

| P0 candidate | Confidence | Why |
|---|---|---|
| `buf_ls` (or `protols`) for `proto` | High | Pi-lens already has `prisma` as a model; proto is the obvious gap. `ensureTool` strategy is straightforward (brew/cargo). |
| `sqls` / `postgres_lsp` for `sql` | High | `sqlfluff` runner exists; LSP is the warm twin. `lspCapable: false` in `language-policy.ts:79` is a one-line flip. |
| `graphql` (graphql-lsp) for new `graphql` FileKind | High | `LANGUAGE_EXTENSIONS` already has `.graphql`/`.gql` (`language.ts:144-145`). The new FileKind addition is a small change. |
| `marksman` for `markdown` | Medium | Markdown edits are high-traffic. Risk: `marksman` is a single-binary download, not npm — adds a github-install strategy (`installer/index.ts:arch`). Already proven pattern (gleam, clojure-lsp both do this). |
| `cspell_ls` auxiliary | High | Pure reuse of the opengrep pattern. Cspell has cspell.json in most repos. |
| `vale_ls` auxiliary | High | Same. |
| `zizmor` auxiliary | High | Same. GitHub Actions security gap, no current runner. |
| `biome` LSP auxiliary | Medium | Conflict with existing `biome` runner + formatter. Need to gate on "no `biome.json` in cwd" so we don't double-fire. |
| `harper_ls` auxiliary | Medium | Filetypes: 30+ — high value but large surface. Risk: spelling/grammar false positives can be noisy; the kill-switch + curated-config gate is the right defense. |

---

## 8. Open questions

- **Auxiliary on `markdown` — primary or auxiliary?** `marksman` is a per-file server (single-file) but produces findings that depend on cross-file references. The `with-auxiliary` collection would lump it in with the security scanners and apply the wrong semantic policy. Stronger case for primary (add `markdown` to `LANGUAGE_POLICY` with `lspCapable: true`) and a separate `markdown` dispatch group.
- **Should `biome` LSP be a runner replacement, not an auxiliary?** The existing `biome` runner is the right primary when `biome.json` is present; an LSP is strictly worse in that case (more startup cost, no `--write` autofix). The auxiliary position is correct (only fires when the project hasn't opted into biome).
- **Auxiliary telemetry:** each new auxiliary increases the per-edit work. `runners/lsp.ts:111-118` passes `auxiliaryServerIds` to `touchFile`; the with-auxiliary collection has a per-server deadline (`server-strategies.ts`, #242). Need to verify the deadlines don't compound — adding 4 new auxiliaries to the same `Promise.all` could exceed `LSP_DIAGNOSTICS_WAIT_MS = 2500ms` (`runners/lsp.ts:33`). Per-#242 the deadline is per-server, so the ceiling is correct, but the floor is `max(callerCap, maxStrategyWait)`, which is still global. **Should this be a per-auxiliary floor rather than a global one?** Worth surfacing in the next strategy pass.
- **`ts_query_ls` `init_options` — file-watching or in-memory?** The nvim entry sets `parser_install_directories` and `parser_aliases`. Both can be derived from `cwd` in `beforeInit`. The `parser_install_directories` should be `{ joinpath(cwd, ".pi-lens/parsers"), "~/.pi-lens/parsers" }` so the first session to encounter a project pays the parser-download cost. Worth a dedicated design.

---

*Generated by an automated review of `https://github.com/neovim/nvim-lspconfig/blob/master/doc/configs.md` (response id `mqmmluk7-8095d522`) against the pi-lens codebase. Re-run the nvim doc fetch when their release notes mention new server entries (typically monthly).*
