Files
pi-lsp/AGENTS.md

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

  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<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           — 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

  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.