Files
pi-lsp/AGENTS.md
Evan Reichard 46e3cc4ccd feat(config): add per-repo .pi-lsp.json server overrides
Users can now drop a .pi-lsp.json at any ancestor of their working
files to add new LSP servers, override built-in ones, or disable
servers entirely. The nearest config (walking upward) wins.

- New src/config.ts: walks upward for .pi-lsp.json, parses, and
  merges with the built-in registry. Cached per config-file path
  with mtime invalidation. Falls back to built-ins on parse error.
- Merge rules: matching id shallow-merges (user wins); new id
  appends (must include match/command/args/rootMarkers); `disable`
  filters at the end.
- src/root.ts: pickServer() now resolves servers via the per-repo
  registry. Adds findServerById(filePath, id) and re-exports
  getServersForPath() for callers.
- src/daemon.ts: getOrCreateEntry() resolves serverId against the
  filePath's config so spawned servers reflect repo overrides.
- index.ts and cli.ts: replace direct `servers` imports with
  path-aware getServersForPath() lookups.
- Tests: 9 new unit tests covering merge semantics, walk-up
  discovery, mtime invalidation, and graceful fallback.
- Docs: README "Per-Repo Config" section + AGENTS.md updates.
2026-05-07 22:43:41 -04:00

134 lines
6.9 KiB
Markdown

# pi-lsp — LSP Extension for pi Coding Agent
## Overview
A [pi coding agent](https://github.com/mariozechner/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
1. First LSP tool call for a file triggers `getOrCreateEntry()` in the daemon
2. `pickServer()` matches the file extension against `server.ts` registry
3. `findRoot()` walks upward looking for root markers (e.g., `go.mod`, `tsconfig.json`)
4. A new `LspClient` is spawned with the caller/session environment from the daemon request, initialized via LSP `initialize`/`initialized`, and waited on (`waitForReady()`)
5. The file is synced via `didOpen` or `didChange` (based on mtime comparison)
6. 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** → `didOpen` with full file contents
- **mtime changed** → `didChange` with 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 — 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
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 supply `match`, `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:
```typescript
{
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
1. Extend the `LspCommand` union in `src/types.ts`
2. Add a handler in `src/commands.ts` (maps command name → LSP method)
3. Register a tool in `index.ts` if it should be callable by the LLM
4. Update `cli.ts` method 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=1` to forward language server stderr to the daemon log
- Use `npm run lsp -- <file> <command> '<json>'` for CLI testing
- Use `npm run lsp -- daemon status` to 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-name` in the TUI. Use `ctx.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 to `tool_result` and runs diagnostics after `edit`/`write`.
All tool execute functions receive `(toolCallId, params, signal, onUpdate, ctx)` where `ctx` is the `ExtensionContext`.