diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a347c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# 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`. + +### 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, 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`. 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 — 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: + +```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 -- ''` 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`.