LSP servers maintain their own workspace index built at initialize time and rely on the client to push file-system events. Previously the daemon only synced the single file being queried, so externally created/changed files (codegen, build scripts, git checkout, the agent's own writes from the perspective of other open files) left the server's index stale until manual /lsp-destroy. Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch) that translates FS events into workspace/didChangeWatchedFiles batches. Patterns come from the server via client/registerCapability (no speculative watching). Ignores layer a tiny baseline (.git, .DS_Store) over the repo's root .gitignore, with a fallback list for non-git workspaces. Events debounce 50ms quiet / 500ms max wait. Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather than relative ones, so compileWatchers() matches each event against both relative and absolute path forms. Caught by the integration test; unit regression test added. Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation. - src/client.ts: honor register/unregisterCapability for workspace/didChangeWatchedFiles; advertise dynamicRegistration; expose getFileWatchers/onWatchersChanged/sendNotification - src/watcher.ts: new WorkspaceWatcher with layered ignores, debounce+batch, Created+Deleted coalescing, dual-form glob matching - src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS, LSP_DEBUG-gated pattern/event logging - test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir - test/integration/watcher-gopls.test.ts: end-to-end against gopls - AGENTS.md: new "Workspace File Watching" section - flake.nix: add go (required by gopls integration test)
8.8 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, destroy_server. request and diagnostics include a launch context with the caller env. The env is used only when spawning a new server for (server.id, rootDir); existing running servers keep their original process env until idle eviction or manual destroy/restart.
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 with the caller/session environment from the daemon request, 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.
Workspace File Watching
Each ClientEntry lazily owns a WorkspaceWatcher (src/watcher.ts,
chokidar + picomatch) that translates filesystem events into
workspace/didChangeWatchedFiles notifications. This keeps the server's
workspace index fresh when files are created/changed/deleted outside of
LSP tool calls (build scripts, codegen, git checkout, the agent's own
file writes).
Non-obvious bits:
- Patterns come from the server. We honor
client/registerCapabilityforworkspace/didChangeWatchedFilesand store the registrations on theLspClient. Don't re-stub those handlers; they look harmless but break the entire feature. If a server doesn't register, we don't watch. - Servers send mixed pattern forms. Gopls registers absolute-path
globs (
/abs/root/**/*.go); others send relative (**/*.ts) orRelativePatternobjects.compileWatchers()tries both relative and absolute matching against each event so we accept all forms. - Ignore layering. Always-ignore baseline (
.git/,.DS_Store) + root.gitignoreparsed via theignorepackage + a small fallback for non-git workspaces. Nested gitignores aren't supported yet. - Debounce. 50ms quiet period, capped at 500ms max wait so sustained event streams (branch switches) still flush in bounded time.
- Watcher and mtime-sync coexist. When the agent edits a file we'll
emit
didChangeWatchedFilesand the next request'ssyncFilewill send adidChange. Servers treat the two as orthogonal (workspace index vs. editor buffer) and dedupe internally. This matches VS Code. - Rollback.
PI_LSP_DISABLE_WATCHERS=1short-circuits all watcher creation — if something goes wrong in a real workspace, this restores the prior "only the queried file is synced" behavior.
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 — Built-in 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, file-watcher registrations
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
commands.ts — CLI command dispatcher (maps command names → LSP methods)
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic
root.ts — pickServer(), findServerById(), getServersForPath(), findRoot(), URI/path conversion
types.ts — ServerConfig interface, LspCommand union
Per-Repo Config (.pi-lsp.json)
Users can add/override/disable servers without editing server.ts. src/config.ts
walks upward from a given path to find .pi-lsp.json, parses it, and merges
with the built-in servers list:
- New
id→ appended (must supplymatch,command,args,rootMarkers). - Existing
id→ shallow-merged over the built-in (user fields win). disable: []→ filtered out at the end.
Results are cached per config path, invalidated by mtime. getServersForPath(p)
is the single entry point — don't import the raw servers array from
server.ts outside src/config.ts. The daemon resolves servers at
getOrCreateEntry() time via findServerById(filePath, id), so spawned
servers reflect the config of the file being acted on. Already-running
entries don't see config changes; users must /lsp-destroy to respawn.
Adding a Server (Built-In)
For servers shipped with pi-lsp, edit server.ts. (For per-repo additions,
users should drop a .pi-lsp.json at the repo root — see README.) 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.