5.5 KiB
pi-lsp — LSP Extension for pi Coding Agent
Overview
A pi coding agent extension that provides LSP tools (lsp_hover, lsp_definition, etc.) to the LLM, plus automatic diagnostics after edits. It runs a long-lived background daemon so language servers stay warm across tool calls instead of cold-starting on every request.
Non-Obvious Things (Read First)
Two-Process Architecture
The extension has two separate Node.js processes communicating over a Unix socket:
| Layer | File(s) | Runs In | Responsibility |
|---|---|---|---|
| Extension | index.ts |
pi's process | Registers tools/commands, calls into daemon, formats responses |
| Daemon | src/daemon.ts + src/client.ts |
Detached background process | Owns LspClient instances, spawns/manages language server processes |
The extension is stateless — it opens a fresh socket connection per request. The daemon is stateful — it caches one LSP server per (server.id, rootDir) and evicts on idle timeout.
Daemon Protocol
Communication is newline-delimited JSON (NDJSON) over a Unix socket at $XDG_RUNTIME_DIR/pi-lsp-$UID.sock. Each line is one independent request/response pair with an id field for matching. See src/daemonProtocol.ts for the type definitions (DaemonRequest, DaemonResponse).
Current ops: request, diagnostics, status, shutdown.
Server Lifecycle
- First LSP tool call for a file triggers
getOrCreateEntry()in the daemon pickServer()matches the file extension againstserver.tsregistryfindRoot()walks upward looking for root markers (e.g.,go.mod,tsconfig.json)- A new
LspClientis spawned, initialized via LSPinitialize/initialized, and waited on (waitForReady()) - The file is synced via
didOpenordidChange(based on mtime comparison) - On idle timeout (default 5 min), the entry is evicted and the server process killed
File Sync Strategy
The daemon tracks opened files per-entry in a Map<uri, mtimeMs>. On each request:
- First access →
didOpenwith full file contents - mtime changed →
didChangewith full text replacement - mtime unchanged → skip (server already has it)
A per-entry serializer promise chain prevents concurrent syncs from racing.
Extension vs Daemon Responsibilities
| Concern | Where |
|---|---|
Which server handles .go / .ts? |
Both — server.ts is shared, but extension calls pickServer() for tools, daemon calls it for caching |
| Spawning/killing server processes | Daemon only |
| Formatting LSP responses for the LLM | Extension only (formatHover, formatDefinition, etc.) |
Auto-diagnostics after edit/write |
Extension (listens to tool_result event) |
CLI one-shot mode (--no-daemon) |
cli.ts directly uses src/client.ts, bypassing daemon |
Project Structure
index.ts — Extension entry point (tools, commands, auto-check flag)
server.ts — LSP server registry (gopls, typescript-language-server, pyright)
cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
daemon.ts — Entrypoint that starts the daemon process
src/
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
commands.ts — CLI command dispatcher (maps command names → LSP methods)
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic
root.ts — pickServer(), findRoot(), URI/path conversion
types.ts — ServerConfig interface, LspCommand union
Adding a Server
Edit server.ts. Add an entry to the servers array:
{
id: "rust-analyzer",
match: ["rs"],
command: "rust-analyzer",
args: [],
rootMarkers: ["Cargo.toml"],
languageId: "rust",
}
The command must be on PATH. No other code changes needed — the daemon and extension pick it up automatically via pickServer().
Adding a Command
- Extend the
LspCommandunion insrc/types.ts - Add a handler in
src/commands.ts(maps command name → LSP method) - Register a tool in
index.tsif it should be callable by the LLM - Update
cli.tsmethod map if it should work via CLI
Development Workflow
- No build step — everything runs via
tsx(TypeScript executor) - Extension is loaded by pi from
~/.pi/extensions/lsp/or.pi/extensions/lsp/ - Daemon is autospawned on first LSP request; logs to
/tmp/pi-lsp-daemon.log - Set
LSP_DEBUG=1to forward language server stderr to the daemon log - Use
npm run lsp -- <file> <command> '<json>'for CLI testing - Use
npm run lsp -- daemon statusto inspect running servers
Extension API Conventions
The extension uses pi's ExtensionAPI (from @mariozechner/pi-coding-agent):
- Tools — registered via
pi.registerTool(), callable by the LLM. Parameters use TypeBox schemas. - Commands — registered via
pi.registerCommand(), invoked as/cmd-namein the TUI. Usectx.ui.notify()for feedback. - Flags — registered via
pi.registerFlag(), accessed as CLI args (e.g.,--lsp-auto-check=false). - Events — subscribed via
pi.on(). The auto-check feature listens totool_resultand runs diagnostics afteredit/write.
All tool execute functions receive (toolCallId, params, signal, onUpdate, ctx) where ctx is the ExtensionContext.