Language Server Protocol integration for the pi coding agent. This fork is maintained by Close for its development environment. It provides six LSP tools, per-session server management, crash retry, and a /lsp inspector.
This repository is Close's maintained fork of code-yeongyu/pi-lsp-client. Close updates it for the pi versions used internally and publishes releases from closeio/pi-lsp-client.
The upstream package ports LSP tools written for oh-my-openagent (omo) by Yeongyu Kim (@code-yeongyu). The original implementation lives under src/tools/lsp/ in that repository.
Yeongyu re-licensed the ported source under MIT for the pi-coding-agent ecosystem. omo remains under SUL-1.0. The MIT license applies only to the code shipped in this repository. See LICENSE and NOTICE.
> Show me the type errors in src/foo.ts.
[lsp_diagnostics] src/foo.ts
E:2 W:1 • 1 file
E 14:5 Type 'string' is not assignable to type 'number'.
E 27:1 Cannot find name 'unknownVar'.
W 9:3 'helper' is declared but its value is never read.
> Rename `oldFoo` to `newFoo` everywhere.
[lsp_prepare_rename] src/lib.ts:42:7
Rename available at 42:7-42:13 (current: "oldFoo")
[lsp_rename] src/lib.ts:42:7 → "newFoo"
✓ Applied 7 edits to 4 files
- src/lib.ts
- src/cli.ts
- src/main.ts
- test/lib.test.ts
Install an exact release from git:
pi install https://github.com/closeio/pi-lsp-client@v0.1.0Full version tags never move. To track the latest release within a major version, install its floating major tag instead:
pi install https://github.com/closeio/pi-lsp-client@v0
pi update --extensionsEach release moves v<major> to the release commit. For example, publishing
v1.2.3 moves v1. pi update --extensions fetches that moved tag and resets
the installed package to its commit. It does not change an exact v1.2.3
installation.
Use pi install -l with either form to record the package in the current
project's .pi/settings.json. For development, load the checkout directly with
pi -e /path/to/pi-lsp-client/src/index.ts.
After installation, restart pi (or run /reload inside an interactive session). All six tools register automatically and become callable by the LLM.
Errors, warnings, and hints from the language server BEFORE running build. Works for both single files and directories (extension auto-detected).
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
File or directory path. |
severity |
"error" | "warning" | "information" | "hint" | "all" (optional) |
Filter by severity. Default all. |
Jump to the definition of the symbol at a given position.
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
Source file. |
line |
number (required) |
1-based line number. |
character |
number (required) |
0-based column. |
Find all usages of the symbol at a given position across the entire workspace.
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
Source file. |
line |
number (required) |
1-based line number. |
character |
number (required) |
0-based column. |
includeDeclaration |
boolean (optional) |
Include the declaration itself. Default true. |
Document outline (scope: "document") or workspace-wide symbol search (scope: "workspace", requires query).
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
Source file (used as LSP context). |
scope |
"document" | "workspace" (required) |
Outline vs search. |
query |
string (optional) |
Symbol name (required for workspace scope). |
limit |
number (optional) |
Max results. Default 200. |
Validate that a rename is possible at a given position. Always run this before lsp_rename.
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
Source file. |
line |
number (required) |
1-based line number. |
character |
number (required) |
0-based column. |
Apply a rename across the workspace. Mutates files. Runs executionMode: "sequential" so it never races other mutating tools.
| Parameter | Type | Description |
|---|---|---|
filePath |
string (required) |
Source file. |
line |
number (required) |
1-based line number. |
character |
number (required) |
0-based column. |
newName |
string (required) |
New symbol name. |
Interactive inspector for the current session's server pool. Shows server id, root, refCount, pendingWaiters, lastUsedAt, isInitializing, alive, and command. Press Escape or Ctrl-C to close. In non-interactive mode (no TUI), prints a one-line summary instead.
One-line summary of which servers are installed (no full inspector).
Run the documented install recipe for <serverId> (whitelisted in AUTO_INSTALLABLE_SERVERS). Status footer shows Installing <id>... while the install runs. On success, notifies; on failure, shows the install command's stderr tail.
If <serverId> is not in the auto-installable whitelist, the command surfaces the manual install hint from LSP_INSTALL_HINTS instead of running anything.
Spawn and initialize an installed server in the current working directory without waiting for any tool call. Useful before a long batch of LSP-driven work to amortize first-call latency.
40+ language servers from omo's BUILTIN_SERVERS, including TypeScript (typescript-language-server), Python (pyright, basedpyright, ruff, ty, pyrefly), Go (gopls), Rust (rust-analyzer), C/C++ (clangd), Ruby (ruby-lsp), Bash (bash-language-server), YAML (yaml-language-server), Lua, Java, PHP, Dart, Swift, Kotlin, Zig, Nix, Haskell, Elixir, OCaml, Terraform, and more.
Each server has an installed check (PATH probe + extension probing) and an install hint (LSP_INSTALL_HINTS). A subset is auto-installable via /lsp install <id> (AUTO_INSTALLABLE_SERVERS).
Rust is manual-only: /lsp install rust prints the rust-analyzer install hint instead of running rustup. If rust-analyzer exits while loading rust-src, repair the active toolchain with rustup component remove rust-src and rustup component add rust-src, then warm up Rust again.
Add custom servers by creating either:
.pi/lsp-client.jsonin the directory you launchpifrom (project-local, takes priority)<pi agent dir>/lsp-client.json(user-global)
The user-global path follows pi's own convention. By default that's ~/.pi/agent/lsp-client.json, but it honors the PI_CODING_AGENT_DIR environment variable (the same one pi reads for its agent directory). If PI_CODING_AGENT_DIR=/path/to/agent, the user config is read from /path/to/agent/lsp-client.json.
For backward compatibility, an old ~/.pi/lsp-client.json is still read if the primary agent-dir path does not exist.
disabled: true removes a builtin server from resolution. Project config wins over user config. Builtins are the lowest priority (only used when no project/user override exists).
- Per-session managers. Each pi session owns its own
LspManager, resolved lazily on first use viagetManagerForSession(ctx.sessionManager)frommanager-registry.js. Concurrent sub-agent sessions in one Node process do not share clients - one session'ssession_shutdowncannot dispose servers another session is still using. Managers are keyed by reference identity ofctx.sessionManagerin aWeakMap, so a forgotten session is GC-safe. - Lazy spawn. Servers spawn on first tool call for a matching extension. No eager warmup of the entire registry.
- Refcount. Each
withLspClient(...)call increments refCount on entry and decrements infinally. Idle reaping fires only when refCount hits zero AND lastUsedAt is older than the idle timeout. - Idle timeout: 5 minutes. Idle clients are stopped and removed from the pool.
- Init timeout: 60 seconds. A pending init older than 60s is reaped, even if other callers are waiting on it.
- Abort-aware acquisition.
getClient(root, server, signal?)participates in tool cancellation. If the signal aborts before init resolves, the caller is removed from the waiter list; if no callers remain, the initializing client is stopped and removed. - Crash retry. When the JSON-RPC transport throws
LspConnectionClosedErrororLspProcessExitedErrormid-call, the wrapper evicts the dead client and retries exactly once for idempotent read tools (diagnostics,goto_definition,find_references,symbols,prepare_rename). Mutating tools (rename) are never retried. - Session shutdown is the primary cleanup boundary.
pi.on("session_shutdown", ...)callsdisposeManagerForSession(ctx.sessionManager)- stops that session's clients, clears its reaper interval, unregisters its process exit fallback, and clearspi-lspstatus/widget keys. Other sessions' managers are untouched. - No raw signal handlers. No
SIGINT/SIGTERMlisteners. They would conflict with pi's TUI shutdown. Each manager registers a synchronousprocess.on("exit", ...)killSynchandler as a fallback for unexpected exits. The disposer removes that handler so listener count doesn't grow across/reload.
- Subprocess spawning uses
node:child_process.spawneverywhere. On Windows the spawn helper enablesshell: trueand includes platform-specific path probing (PATHEXT,Pathcasing). - The transport uses
vscode-jsonrpc/nodedirectly with the spawned process'sstdin/stdoutstreams. No Bun-specific Web stream adapter.
| Symptom | Fix |
|---|---|
| "LSP server '' is configured but NOT INSTALLED." | Run /lsp install <id> if the server is in the auto-installable whitelist, otherwise install it manually using the displayed hint. |
| "No LSP server configured for extension: .ext" | Add a custom entry in .pi/lsp-client.json (see Custom Servers). |
rust-analyzer exited while loading rust-src |
Run rustup component remove rust-src and rustup component add rust-src for the active toolchain, then retry the LSP tool or /lsp warmup rust. |
lsp_rename did not retry after a server crash |
This is by design. Mutating tools never auto-retry to avoid double-applying edits. Re-issue the rename manually. |
Footer status stuck after /reload |
File a bug. The session_shutdown handler clears pi-lsp status/widget keys. If they persist, the cleanup boundary was bypassed. |
Stale LSP child after /reload |
Run /lsp to inspect the current snapshot. If getSnapshot() is empty but a child process is still alive, file a bug. stopAll() should have killed it. |
git clone https://github.com/closeio/pi-lsp-client
cd pi-lsp-client
npm install # install dev + peer dependencies
npm test # run vitest
npm run typecheck # strict tsc --noEmit
npm run check # tsc + biome
pi -e ./src/index.ts # smoke-test inside a real pi sessionThe test suite uses vitest. Test descriptions follow #given .. #when .. #then style; bodies use plain // given / // when / // then comments. No any, no enums.
Releases use the version in package.json and a matching v<version> git tag.
For each release:
- Run
npm version patch --no-git-tag-version(orminor/major). - Move the release notes from
[Unreleased]to## [<version>] - YYYY-MM-DDinCHANGELOG.md. - Merge the version change to
mainand wait for CI to pass. - Run the
releaseworkflow frommainin GitHub Actions.
The workflow rejects an existing full version tag or a missing changelog section,
reruns the checks and tests, creates the immutable version tag and GitHub
release, then force-moves the matching v<major> tag. The first v0.1.0
release is already prepared, so it only needs steps 3 and 4.
MIT. See NOTICE for re-license disclosure relative to omo.
- Yeongyu Kim (@code-yeongyu) is the author of the original omo LSP tools and the upstream
pi-lsp-clientpackage this fork is based on. - Mario Zechner (@badlogic) is the author of pi-mono and the pi-coding-agent extension API this package targets.
- Microsoft maintains the vscode-jsonrpc transport library used here.
{ "lsp": { "my-server": { "command": ["my-lsp", "--stdio"], "extensions": [".myext"], "priority": 100, "env": { "MY_LSP_LOG": "1" } }, "biome": { "disabled": true } } }