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

6.9 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

  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 accessdidOpen with full file contents
  • mtime changeddidChange 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:

{
  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.