# LSP bridges — analysis & recommendation

> Research artifact. **Not committed.** Working doc that captures the investigation
> behind [#157](https://github.com/apmantza/pi-lens/issues/157) ("feat: consider
> opt-in lspmux support"): does it make sense for pi-lens to build a separate LSP
> bridge client that spawns servers and makes them available across concurrent
> `pi` sessions, and what are the trade-offs of the existing reference bridges?

## TL;DR

**No, do not build a general-purpose LSP bridge on top of pi-lens.**

- The premise ("x2 sessions spawn x2 LSPs") is only true for **plain `pi` in two
  separate processes** on the same workspace. All other cases either already
  share (per-process singleton, MCP warm server) or aren't a real workload.
- The two reference bridges solve different problems and neither is a clean fit:
  - [lsp-bridge](https://github.com/manateelazycat/lsp-bridge) is an
    *async-offload* bridge shaped for Emacs's single-threaded runtime — wrong
    shape (Elisp + EPC + Python event loop + acm completion UI; no TUI to
    integrate into).
  - [lspmux](https://codeberg.org/p2502/lspmux) is the right shape (N clients → 1
    server per workspace) but **drops server→client messages** during ID
    remapping, which breaks `client/registerCapability` (used by pi-lens for
    `typescript/willRenameFile`, file watcher registration, inlay hint providers),
    `window/workDoneProgress/create`, and `$/progress`.
- The LSP spec itself marks multi-client-per-server out-of-scope
  ([microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160))
  and Claude Code hit the same problem and closed their tracker
  ([anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673))
  without shipping a shared-instance solution.
- The cheaper, in-tree alternative worth scoping: a `pi-lens-mcp-lspd` daemon —
  long-lived LSP-only process the existing `LSPService` socket-attaches to.
  Reuses existing client code, warm-channel pattern, and per-server install.
  No protocol rewriting, no per-server re-testing.

**Recommendation:** gate both on **measurement**. If two `pi` processes on a real
workspace are actually 2× memory / 2× startup cost on a workload the user cares
about, scope Phase 1 (`pi-lens-mcp-lspd`). Otherwise, leave [#157](https://github.com/apmantza/pi-lens/issues/157)
open as-is and the MCP path continues to cover the highest-cost user (Claude
Code + `pi-lens-analyze`).

## Background: what was being investigated

The question that motivated this analysis:

> "we want to assess whether building a separate client that could spawn lsps
> and make them available for different pi sessions that go on concurrently
> would make sense. i assume that currently if we are running x2 different
> sessions in a typescript server we spawn x2 ts lsps."

Two reference bridges were named:
[manateelazycat/lsp-bridge](https://github.com/manateelazycat/lsp-bridge) and
[p2502/lspmux](https://codeberg.org/p2502/lspmux). The hypothesis was that
multiple `pi` sessions on the same workspace each spawn an independent `tsserver`
(etc.) and waste 300-500MB+ of RAM and several seconds of indexing time per
extra session.

The investigation confirms the **cost is real** for some scenarios (cross-process
plain `pi`, or any agent that doesn't go through the MCP warm path) but **already
solved** for others (MCP warm server + PostToolUse hook). The bridges themselves
turn out to be poor fits for pi-lens — for architectural reasons specific to
how pi-lens uses LSP, not just "we'd have to maintain another dep."

## Current state in pi-lens (what already shares what)

`LSPService` is a per-Node-process singleton:

```ts
// clients/lsp/index.ts:2018-2033
let globalLSPService: LSPService | null = null;

export function getLSPService(): LSPService {
    if (!globalLSPService) globalLSPService = new LSPService();
    return globalLSPService;
}

export function resetLSPService(options: LSPShutdownOptions = {}): void {
    if (globalLSPService) {
        globalLSPService.shutdown(options).catch(() => {});
    }
    globalLSPService = null;
}
```

The `state.clients` map is keyed by `"serverId:root"`
(`clients/lsp/index.ts:53`), so the service deduplicates per-server per-workspace
**within one process**. The matrix:

| Scenario | Shared? | Why |
|---|---|---|
| Sub-agents / forked contexts in one `pi` invocation | ✅ | Same process → same singleton → same `tsserver` instance per workspace root |
| One `pi` process, multiple `session_start` events with overlapping roots | ✅ | `resetLSPService({ fast: true })` tears down but the new session re-attaches to still-living warm clients in the warm channel (`mcp/server.ts` `auto session_start` path) |
| **Two separate `pi` processes** on the same workspace (two terminals) | ❌ | Each Node process has its own `globalLSPService`; each spawns its own `tsserver`/`rust-analyzer`/etc. — same gap as [anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673) |
| Two Claude Code sessions + `pi-lens-analyze` PostToolUse hook | ✅ | The MCP server is a long-lived separate process that holds LSP clients warm and is shared across sessions. The hook routes through the warm IPC side-channel (`clients/mcp/ipc.ts` — per-workspace stable socket/pipe = sha256 of resolved cwd), so both sessions hit the same `tsserver` instance |

The "x2 ts lsps for x2 sessions" claim is therefore only true for the
cross-process plain-`pi` case, and even there the cost is bounded:

- A single `tsserver` is ~300-500MB on a typical TS project
- A single `rust-analyzer` is 500MB-2GB+ on large Rust monorepos
- A single `clangd` is 200-500MB on large C++ projects
- A single `jdtls` is 1-2GB on large Java projects

So the cost matters most for **plain `pi` on large monorepos** with a heavy
server. For small/medium projects with light servers (pylsp, gopls on small
modules), the cost is negligible.

## Bridges evaluated

### lsp-bridge (manateelazycat)

- **Repo:** <https://github.com/manateelazycat/lsp-bridge>
- **Architecture (from the repo framework + EmacsConf 2022 talk):**
  ```
  Elisp client ──EPC──> Python backend ──stdio──> many LSP servers
       ▲                       ▲
       │                       │
  acm/ completion UI    core/handler/ per-LSP-method handlers
  ```
- **Primary motivation:** *async offload* — move heavy LSP work out of Emacs's
  single-threaded runtime. The Python event loop handles async requests without
  blocking the editor.
- **Multi-session sharing:** not a primary goal. Single Emacs → single Python →
  many servers. Each server still 1:1 with the bridge.
- **Why it's the wrong fit for pi-lens:**
  - The Elisp half is irrelevant (we don't have a TUI to integrate into — pi
    has its own).
  - The acm/ completion UI is the whole reason the bridge exists for Emacs; we
    have no equivalent in pi-lens (we surface diagnostics as agent context
    injections, not as a completion menu).
  - The Python event loop doesn't help: Node is async-by-default and the LSP
    pipeline in pi-lens is already fully async (see `clients/safe-spawn.ts`,
    `clients/lsp/index.ts`).
  - Porting the architecture would mean rebuilding the Elisp/Python parts
    without gaining the UI integration that motivated the design.
- **Verdict:** ❌ wrong shape.

### lspmux (p2502)

- **Repo:** <https://codeberg.org/p2502/lspmux> (formerly `ra-multiplex` at
  <https://crates.io/crates/ra-multiplex>).
- **Architecture (from the README):**
  ```
  N editor clients ──socket──> lspmux server ──stdio──> 1 LSP server per workspace
       ▲                          ▲
       │                          │
  lspmux client shim       per-workspace + per-env
  (impersonates the        server lifecycle, with
  LSP binary on stdout)    ID remapping on the wire
  ```
  Editors that support socket transport (e.g. nvim with custom init) can
  connect directly to the lspmux server and skip the shim.
- **Primary motivation:** N clients → 1 server per workspace, for RAM savings
  on heavy servers (originally rust-analyzer; now generalized).
- **The trade-off, from the README:**
  > "Because neither LSP nor language servers (usually) support multiple clients
  > per server lspmux intercepts the handshake process and modifies IDs of
  > requests and responses to track which response belongs to which client.
  > Because not all messages can be tracked this way it drops some, notably it
  > drops any requests from the server, this may cause some issues for you, if
  > you run into any issues which are definitely not present in the language
  > server alone please open an issue!"
- **What specifically breaks for pi-lens (concrete):**
  - `client/registerCapability` — pi-lens uses this for `typescript/willRenameFile`
    (rename preview), file watcher registration (`workspace/didChangeWatchedFiles`),
    and inlay hint providers. The `executeCommand` allowlist in
    `LSPService.getAdvertisedCommands()` (per-server advertised commands
    captured at initialize) is downstream of this — a server can't register
    new commands after init if lspmux drops its `client/registerCapability`
    request from the client side, and can't send `workspace/executeCommand`
    requests back to the client.
  - `window/workDoneProgress/create` + `$/progress` — TypeScript's "Indexing..."
    progress UI in the status bar, gopls/rust-analyzer progress reporting, and
    any other server-originated progress channel.
  - Per-client workspace folder scoping — lspmux hands the server a *single*
    root (per `(workspace, environment)` key). If two clients on the same
    lspmux instance have different workspace roots (e.g. different sub-projects
    in a monorepo, different branches), they collide. Pi-lens's
    `resolveLanguageRootForFile` resolves per-file workspace roots; that
    resolution would be lost.
  - `workspace/diagnostic` pull diagnostics from different clients would
    collide on the same server.
- **What works fine in lspmux (rust-analyzer is the proven target):**
  - `textDocument/publishDiagnostics` (push diagnostics, the common case)
  - `textDocument/definition`, `references`, `hover`, `completion`,
    `documentSymbol` — basic read-only operations
  - `textDocument/didOpen`, `didChange`, `didClose` — most state stays
    consistent because the remapping is request-scoped
- **Verdict:** ❌ not safe for pi-lens as a general solution. Could be safe
  per-server with an allowlist (rust-analyzer is the proven safe target;
  typescript-language-server is not, due to `client/registerCapability`).

### Zed / sourcegraph / "lsp-proxy" / others

- **Zed:** No built-in "shared rust-analyzer" daemon. The conventional answer
  in the ecosystem is lspmux, same as above. The
  [Rust on Zed docs](https://zed.dev/docs/languages/rust) mention no
  cross-instance sharing. (Zed's extensions are per-editor-instance.)
- **sourcegraph:** No public "lsp-proxy" daemon. The closest is the
  [codescout::lsp::mux::protocol](https://docs.rs/codescout/latest/codescout/lsp/mux/protocol/index.html)
  Rust crate which is the lspmux-protocol library extracted for reuse — same
  project.
- **Other AI-agent codegen tools (Cursor, Cody, Continue, etc.):** All hit
  the same problem (each editor session spawns its own LSP). None ship a
  shared-instance solution. Claude Code closed the equivalent tracker
  ([#28673](https://github.com/anthropics/claude-code/issues/28673)) without
  shipping a solution — proposed "socket transport with PID file probe" but
  it was not implemented.

### Claude Code's own problem (#28673, closed)

The most directly relevant prior art. Quoting from the issue body:

> The rust-analyzer-lsp plugin (and any LSP plugin using the declarative
> `lspServers` config) spawns a new LSP server process per Claude Code
> session. For `rust-analyzer`, each instance independently loads and indexes
> the entire project, consuming 500MB–2GB+ of memory. Running multiple Claude
> Code sessions against the same project means multiple redundant instances of
> the same server, all holding duplicate indexes in memory.
>
> Current Architecture: The LSP Manager reads `lspServers` config from the
> plugin manifest, spawns a child process per session via stdio, manages a
> 1:1 client-server lifecycle tied to the session. Stdio transport is
> inherently single-client — there's no mechanism to discover or reuse an
> existing server process.
>
> Proposed Solution: Add an option for LSP server definitions to share a
> single server process across sessions working on the same project. Two
> possible approaches: A) Socket-based transport with process reuse, B) A
> dedicated LSP proxy that multiplexes clients.

Status: **closed** without a shipped solution. The proposed approaches
(socket-based + PID probe, or external proxy like lspmux) are exactly the
two options in this analysis; neither was implemented in Claude Code.

This is informative for pi-lens: even a much larger team with explicit
motivation (Anthropic) found the cost of the right solution too high to
ship. The lighter alternative (in-tree daemon) is the lower-friction path.

## The LSP spec itself

[microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160)
remains **open** with this statement in the spec:

> The protocol currently assumes that one server serves one tool. There is
> currently no support in the protocol to share one server between different
> tools. Such a sharing would require additional protocol e.g. to lock a
> document to support concurrent editing.

Multi-client-per-server is not a designed-in scenario. Any bridge that
attempts it is working against the protocol, not with it. Lspmux's "drop
server→client messages" trade-off is the symptom.

## Alternative: in-tree `pi-lens-mcp-lspd` daemon

A much smaller change that closes the plain-`pi` cross-process gap without
any of the lspmux breakage.

### Design

- **A new long-lived process** (`dist/mcp/lspd.js`) that:
  - Owns one `LSPService` singleton (same code as the MCP server)
  - Listens on a per-workspace stable endpoint, derived the same way as
    `clients/mcp/ipc.ts:ipcPathForCwd` (sha256 of resolved cwd → named pipe
    on Windows, Unix socket on POSIX)
  - Speaks the **existing** LSP JSON-RPC protocol over the socket — no
    rewriting, no ID remapping, no message drops
  - When a client connects, hands it its own `LSPClientInfo` (per-client
    `LSPClient` instance backed by a socket child of the actual LSP server
    process) — the LSP server itself runs once per `(serverId, root)`, the
    same way the current `LSPService` deduplicates within a process
- **The existing `LSPService`** is taught a new `transport: "stdio" | "socket"`
  field on its spawn. When `socket` and the daemon is reachable for the
  workspace root, it spawns the daemon and connects to it instead of
  `safeSpawnAsync`'ing the LSP binary directly. When the daemon is
  unreachable, falls back to the current stdio-spawn path (preserves all
  existing behavior; this is opt-in by config).

### What this reuses (no new infrastructure)

- `LSPService` client code (`clients/lsp/index.ts`) — unchanged surface, new
  transport option
- `LSPClient` (`clients/lsp/client.ts`) — the actual JSON-RPC client; swap
  the stdio transport for `net.Socket`/`node:net` named-pipe transport
- The warm-channel pattern in `clients/mcp/ipc.ts:ipcPathForCwd` — already
  cross-platform (Windows named pipe `\\.\pipe\pi-lens-mcp-<sha256prefix>` /
  Unix socket `/tmp/pi-lens-mcp-<sha256prefix>.sock`), already per-workspace
  stable
- The per-server install in `clients/installer/index.ts` — the daemon can
  reuse the same registry of installable servers; only the launch shape
  changes
- The `lsp.json` config surface (per `.pi-lens/lsp.json` or
  `~/.pi-lens/lsp.json`) — opt-in per-server, same as the disabled-servers
  config

### What this does NOT need

- No protocol rewriting. The LSP server is told about exactly one client
  connection (the daemon's), and that client manages the cross-process
  sharing itself with normal LSP semantics.
- No ID remapping. Each connected `pi` process talks JSON-RPC to its own
  `LSPClient` instance in the daemon; the daemon fans messages to the one
  server process. (The server is the only thing shared, not the protocol
  state.)
- No dropping of server→client messages. The daemon forwards everything.
- No per-server re-testing beyond confirming the new transport works for
  each server. The transport is the same JSON-RPC the existing client uses;
  the only change is `stdio` → `socket`.

### What this is NOT (deliberate scope cuts)

- **Not a general LSP multiplexer** — only the LSP server is shared. There is
  one daemon per workspace, not per-server, but each serverId still has its
  own server process. (Sharing one server across multiple workspaces is a
  bigger problem — different root folders, different
  `workspace/didChangeWatchedFiles` subscriptions — that the spec explicitly
  doesn't support.)
- **Not a multi-host daemon** — the socket/pipe is `127.0.0.1` / local
  named-pipe only. No auth, no remote clients. (Cross-host LSP sharing is a
  fundamentally different problem; the filesystem state would have to be
  identical, as the lspmux README itself notes.)
- **Not auto-started** — the user runs `pi-lens-mcp-lspd start` (or it
  auto-starts on first `pi` session if `lsp.json` enables the socket
  transport). Stays off by default; the per-edit cost of one daemon process
  is small but real (Node startup + module load + JSON-RPC server listener).

### Cost estimate

- **New code:** ~500-800 LOC. The daemon is mostly a stripped-down `mcp/server.ts`
  with no MCP transport, no `pilens_analyze`/etc. tools, no dispatcher — just
  a `LSPService` + a JSON-RPC-over-socket bridge.
- **New tests:** ~10-15 unit tests for the daemon (start, connect, request/response
  forwarding, error/fallback, shutdown); one spawn smoke. The existing LSP
  client tests (38 server fixtures) cover the LSP side unchanged.
- **New packaging:** one new bin (`pi-lens-mcp-lspd`) + one new dist entry.
  No new runtime deps.
- **No lockfile change** (no new deps). Per the install constraints in
  AGENTS.md: runtime imports must live in `dependencies`, the host SDK must
  be type-only, `package-lock.json` is committed and must stay in sync.

### Risk

- The new transport is the only piece of new logic; it's a thin `net.Socket`
  ↔ `LSPClient` glue. Most failure modes (server crash, pipe hangup, slow
  client) are already handled in the existing client.
- The main risk is **operational**: users will need to know to start the
  daemon, and a daemon that crashes silently will degrade to the existing
  stdio path. A small health check + `lens-health` integration handles
  this.

## Recommendation

**Phase 0 (do now, small):** the `LSP sharing scope` matrix is not currently
documented in `AGENTS.md`; add it as a load-bearing note so the next agent
doesn't relitigate. (Currently held in
[#157](https://github.com/apmantza/pi-lens/issues/157#issuecomment-4761963228).)

**Phase 1 (gated, in-tree, only if measured cost warrants it):** `pi-lens-mcp-lspd`
daemon as sketched above. Reuses the existing `LSPService` + `clients/mcp/ipc.ts`
warm channel + installer registry. No new deps. Scope as a separate
`feature` sub-issue with `refs #157` once there's a measured workload
demonstrating the cost.

**Phase 2 (only if Phase 1 isn't enough):** lspmux integration, behind a
per-server opt-in, with an explicit `lspmux-safe: [serverIds]` allowlist
(rust-analyzer first; typescript-language-server is NOT safe per the
`client/registerCapability` analysis above). This is the high-cost
phase — it needs per-server testing for capability registration and
progress reporting on every server in `LSP_SERVERS`, plus per-server
fallback when the lspmux shim is missing.

**Do not ship a general-purpose bridge.** lsp-bridge is the wrong shape;
lspmux is the right shape but breaks messages pi-lens uses. The
`pi-lens-mcp-lspd` approach is the only one that doesn't trade correctness
for sharing.

## Gating: when to actually do Phase 1

The MCP path already covers the highest-cost user (Claude Code via
`pi-lens-analyze` PostToolUse hook). The remaining gap is plain `pi` in two
terminals on a large monorepo. Before scoping Phase 1, measure:

1. `ps` / `psm` on a clean workspace while running two `pi` sessions
   side-by-side. Capture RSS per `tsserver` (or the heavy server in use).
2. First-edit latency in each session — second session should pay the cold
   spawn, the first should not.
3. Compare to running both sessions through the MCP warm path (one
   `pi-lens-mcp` server, both clients). If the warm path saves
   meaningfully and the user is happy with it, the plain-`pi` cross-process
   gap is a non-issue for them.

If the cost is real and the MCP path is a non-fit (e.g. the user is on
plain `pi` and doesn't want the MCP overhead), Phase 1 is justified.
Otherwise, leave [#157](https://github.com/apmantza/pi-lens/issues/157) open
and the matrix above is the durable answer.

## Open questions

- **Does pi itself support concurrent sub-agents within one process?** This
  analysis assumes the matrix row "sub-agents / forked contexts in one `pi`
  invocation" is a real workload. If pi only ever has one active session
  per process, that row collapses to "session_start events with overlapping
  roots" and the matrix shrinks. Need to confirm against the pi host SDK
  (`@earendil-works/pi-coding-agent`).
- **How often does the warm MCP path actually save cost in practice?** The
  `pi-lens-analyze` hook is opt-in (user must register it with `claude mcp
  add`). If most users don't register it, the MCP path covers a much
  smaller fraction of the user base than this analysis assumes.
- **Is there an existing Rust/Node LSP multiplexing crate we could embed?**
  `codescout::lsp::mux::protocol` is the lspmux-protocol library. Embedding
  it as a Node N-API module is a viable middle ground (no fork to maintain)
  but still has the same message-dropping trade-off. The Phase 1 design is
  strictly better.

## References

- Bridges:
  - [lsp-bridge](https://github.com/manateelazycat/lsp-bridge) — async-offload
    bridge for Emacs
  - [lspmux](https://codeberg.org/p2502/lspmux) — N clients → 1 server per
    workspace (formerly
    [ra-multiplex](https://docs.rs/crate/ra-multiplex/latest))
  - [codescout::lsp::mux::protocol](https://docs.rs/codescout/latest/codescout/lsp/mux/protocol/index.html) —
    the lspmux-protocol crate extracted for reuse
- LSP spec / related issues:
  - [microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160) —
    "Plans for supporting multiple clients from a single LSP server?" (open)
  - [anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673) —
    "Support shared LSP server instances across sessions" (closed without a
    solution)
- pi-lens code:
  - `clients/lsp/index.ts:2018-2033` — `LSPService` singleton + reset
  - `clients/lsp/index.ts:53` — `state.clients` keyed by `serverId:root`
  - `clients/mcp/ipc.ts:ipcPathForCwd` — per-workspace stable socket/pipe
    naming (sha256 of resolved cwd)
  - `mcp/server.ts` — the long-lived MCP server (the existing
    cross-process-sharing substrate for Claude Code)
  - `docs/mcp.md` — design doc for the MCP server (the pattern a
    `pi-lens-mcp-lspd` would reuse)
- pi-lens issues:
  - [#157](https://github.com/apmantza/pi-lens/issues/157) — "feat: consider
    opt-in lspmux support" (open; the issue this analysis supports)
  - [#157 comment](https://github.com/apmantza/pi-lens/issues/157#issuecomment-4761963228) —
    earlier version of the trade-off analysis (superseded by this doc)
