Commit cbc867e44a06

Vincent Demeester <vincent@sbr.pm>
2026-02-15 22:47:54
feat(pi): add LSP extension with Nix support
Language Server Protocol integration providing auto-diagnostics and on-demand queries (definition, references, hover, symbols, diagnostics, rename, code actions). Added Nix language support (nil/nixd) to the upstream extension from prateekmedia/pi-hooks. Supports TypeScript, Go, Python, Rust, Nix, and more.
1 parent 012ac0a
Changed files (5)
dots/pi/agent/extensions/lsp/.gitignore
@@ -0,0 +1,1 @@
+node_modules
dots/pi/agent/extensions/lsp/lsp-core.ts
@@ -0,0 +1,1142 @@
+/**
+ * LSP Core - Language Server Protocol client management
+ */
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import * as path from "node:path";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import { pathToFileURL, fileURLToPath } from "node:url";
+import {
+  createMessageConnection,
+  StreamMessageReader,
+  StreamMessageWriter,
+  type MessageConnection,
+  InitializeRequest,
+  InitializedNotification,
+  DidOpenTextDocumentNotification,
+  DidChangeTextDocumentNotification,
+  DidCloseTextDocumentNotification,
+  DidSaveTextDocumentNotification,
+  PublishDiagnosticsNotification,
+  DocumentDiagnosticRequest,
+  WorkspaceDiagnosticRequest,
+  DefinitionRequest,
+  ReferencesRequest,
+  HoverRequest,
+  SignatureHelpRequest,
+  DocumentSymbolRequest,
+  RenameRequest,
+  CodeActionRequest,
+} from "vscode-languageserver-protocol/node.js";
+import {
+  type Diagnostic,
+  type Location,
+  type LocationLink,
+  type DocumentSymbol,
+  type SymbolInformation,
+  type Hover,
+  type SignatureHelp,
+  type WorkspaceEdit,
+  type CodeAction,
+  type Command,
+  DiagnosticSeverity,
+  CodeActionKind,
+  DocumentDiagnosticReportKind,
+} from "vscode-languageserver-protocol";
+
+// Config
+const INIT_TIMEOUT_MS = 30000;
+const MAX_OPEN_FILES = 30;
+const IDLE_TIMEOUT_MS = 60_000;
+const CLEANUP_INTERVAL_MS = 30_000;
+
+export const LANGUAGE_IDS: Record<string, string> = {
+  ".dart": "dart", ".ts": "typescript", ".tsx": "typescriptreact",
+  ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript",
+  ".cjs": "javascript", ".mts": "typescript", ".cts": "typescript",
+  ".vue": "vue", ".svelte": "svelte", ".astro": "astro",
+  ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
+  ".kt": "kotlin", ".kts": "kotlin",
+  ".swift": "swift",
+  ".nix": "nix",
+};
+
+// Types
+interface LSPServerConfig {
+  id: string;
+  extensions: string[];
+  findRoot: (file: string, cwd: string) => string | undefined;
+  spawn: (root: string) => Promise<{ process: ChildProcessWithoutNullStreams; initOptions?: Record<string, unknown> } | undefined>;
+}
+
+interface OpenFile { version: number; lastAccess: number; }
+
+interface LSPClient {
+  connection: MessageConnection;
+  process: ChildProcessWithoutNullStreams;
+  diagnostics: Map<string, Diagnostic[]>;
+  openFiles: Map<string, OpenFile>;
+  listeners: Map<string, Array<() => void>>;
+  stderr: string[];
+  capabilities?: any;
+  root: string;
+  closed: boolean;
+}
+
+export interface FileDiagnosticItem {
+  file: string;
+  diagnostics: Diagnostic[];
+  status: 'ok' | 'timeout' | 'error' | 'unsupported';
+  error?: string;
+}
+
+export interface FileDiagnosticsResult { items: FileDiagnosticItem[]; }
+
+// Utilities
+const SEARCH_PATHS = [
+  ...(process.env.PATH?.split(path.delimiter) || []),
+  "/usr/local/bin", "/opt/homebrew/bin",
+  `${process.env.HOME}/.pub-cache/bin`, `${process.env.HOME}/fvm/default/bin`,
+  `${process.env.HOME}/go/bin`, `${process.env.HOME}/.cargo/bin`,
+];
+
+function which(cmd: string): string | undefined {
+  const ext = process.platform === "win32" ? ".exe" : "";
+  for (const dir of SEARCH_PATHS) {
+    const full = path.join(dir, cmd + ext);
+    try { if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } catch {}
+  }
+}
+
+function normalizeFsPath(p: string): string {
+  try {
+    // realpathSync.native is faster on some platforms, but not always present
+    const fn: any = (fs as any).realpathSync?.native || fs.realpathSync;
+    return fn(p);
+  } catch {
+    return p;
+  }
+}
+
+function findNearestFile(startDir: string, targets: string[], stopDir: string): string | undefined {
+  let current = path.resolve(startDir);
+  const stop = path.resolve(stopDir);
+  while (current.length >= stop.length) {
+    for (const t of targets) {
+      const candidate = path.join(current, t);
+      if (fs.existsSync(candidate)) return candidate;
+    }
+    const parent = path.dirname(current);
+    if (parent === current) break;
+    current = parent;
+  }
+}
+
+function findRoot(file: string, cwd: string, markers: string[]): string | undefined {
+  const found = findNearestFile(path.dirname(file), markers, cwd);
+  return found ? path.dirname(found) : undefined;
+}
+
+function timeout<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error(`${name} timed out`)), ms);
+    promise.then(r => { clearTimeout(timer); resolve(r); }, e => { clearTimeout(timer); reject(e); });
+  });
+}
+
+function simpleSpawn(bin: string, args: string[] = ["--stdio"]) {
+  return async (root: string) => {
+    const cmd = which(bin);
+    if (!cmd) return undefined;
+    return { process: spawn(cmd, args, { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
+  };
+}
+
+async function spawnChecked(cmd: string, args: string[], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
+  try {
+    const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
+
+    // If the process exits immediately (e.g. unsupported flag), treat it as a failure
+    return await new Promise((resolve) => {
+      let settled = false;
+
+      const cleanup = () => {
+        child.removeListener("exit", onExit);
+        child.removeListener("error", onError);
+      };
+
+      let timer: NodeJS.Timeout | null = null;
+
+      const finish = (value: ChildProcessWithoutNullStreams | undefined) => {
+        if (settled) return;
+        settled = true;
+        if (timer) clearTimeout(timer);
+        cleanup();
+        resolve(value);
+      };
+
+      const onExit = () => finish(undefined);
+      const onError = () => finish(undefined);
+
+      child.once("exit", onExit);
+      child.once("error", onError);
+
+      timer = setTimeout(() => finish(child), 200);
+      (timer as any).unref?.();
+    });
+  } catch {
+    return undefined;
+  }
+}
+
+async function spawnWithFallback(cmd: string, argsVariants: string[][], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
+  for (const args of argsVariants) {
+    const child = await spawnChecked(cmd, args, cwd);
+    if (child) return child;
+  }
+  return undefined;
+}
+
+function findRootKotlin(file: string, cwd: string): string | undefined {
+  // Prefer Gradle settings root for multi-module projects
+  const gradleRoot = findRoot(file, cwd, ["settings.gradle.kts", "settings.gradle"]);
+  if (gradleRoot) return gradleRoot;
+
+  // Fallbacks for single-module Gradle or Maven builds
+  return findRoot(file, cwd, [
+    "build.gradle.kts",
+    "build.gradle",
+    "gradlew",
+    "gradlew.bat",
+    "gradle.properties",
+    "pom.xml",
+  ]);
+}
+
+function dirContainsNestedProjectFile(dir: string, dirSuffix: string, markerFile: string): boolean {
+  try {
+    const entries = fs.readdirSync(dir, { withFileTypes: true });
+    for (const e of entries) {
+      if (!e.isDirectory()) continue;
+      if (!e.name.endsWith(dirSuffix)) continue;
+      if (fs.existsSync(path.join(dir, e.name, markerFile))) return true;
+    }
+  } catch {
+    // ignore
+  }
+  return false;
+}
+
+function findRootSwift(file: string, cwd: string): string | undefined {
+  let current = path.resolve(path.dirname(file));
+  const stop = path.resolve(cwd);
+
+  while (current.length >= stop.length) {
+    if (fs.existsSync(path.join(current, "Package.swift"))) return current;
+
+    // Xcode projects/workspaces store their marker files *inside* a directory
+    if (dirContainsNestedProjectFile(current, ".xcodeproj", "project.pbxproj")) return current;
+    if (dirContainsNestedProjectFile(current, ".xcworkspace", "contents.xcworkspacedata")) return current;
+
+    const parent = path.dirname(current);
+    if (parent === current) break;
+    current = parent;
+  }
+
+  return undefined;
+}
+
+async function runCommand(cmd: string, args: string[], cwd: string): Promise<boolean> {
+  return await new Promise((resolve) => {
+    try {
+      const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
+      p.on("error", () => resolve(false));
+      p.on("exit", (code) => resolve(code === 0));
+    } catch {
+      resolve(false);
+    }
+  });
+}
+
+async function ensureJetBrainsKotlinLspInstalled(): Promise<string | undefined> {
+  // Opt-in download (to avoid surprising network activity)
+  const allowDownload = process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "1" || process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "true";
+  const installDir = path.join(os.homedir(), ".pi", "agent", "lsp", "kotlin-ls");
+  const launcher = process.platform === "win32"
+    ? path.join(installDir, "kotlin-lsp.cmd")
+    : path.join(installDir, "kotlin-lsp.sh");
+
+  if (fs.existsSync(launcher)) return launcher;
+  if (!allowDownload) return undefined;
+
+  const curl = which("curl");
+  const unzip = which("unzip");
+  if (!curl || !unzip) return undefined;
+
+  try {
+    // Determine latest version
+    const res = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest", {
+      headers: { "User-Agent": "pi-lsp" },
+    });
+    if (!res.ok) return undefined;
+    const release: any = await res.json();
+    const versionRaw = (release?.name || release?.tag_name || "").toString();
+    const version = versionRaw.replace(/^v/, "");
+    if (!version) return undefined;
+
+    // Map platform/arch to JetBrains naming
+    const platform = process.platform;
+    const arch = process.arch;
+
+    let kotlinArch: string = arch;
+    if (arch === "arm64") kotlinArch = "aarch64";
+    else if (arch === "x64") kotlinArch = "x64";
+
+    let kotlinPlatform: string = platform;
+    if (platform === "darwin") kotlinPlatform = "mac";
+    else if (platform === "linux") kotlinPlatform = "linux";
+    else if (platform === "win32") kotlinPlatform = "win";
+
+    const supportedCombos = new Set(["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]);
+    const combo = `${kotlinPlatform}-${kotlinArch}`;
+    if (!supportedCombos.has(combo)) return undefined;
+
+    const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip`;
+    const url = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}`;
+
+    fs.mkdirSync(installDir, { recursive: true });
+    const zipPath = path.join(installDir, "kotlin-lsp.zip");
+
+    const okDownload = await runCommand(curl, ["-L", "-o", zipPath, url], installDir);
+    if (!okDownload || !fs.existsSync(zipPath)) return undefined;
+
+    const okUnzip = await runCommand(unzip, ["-o", zipPath, "-d", installDir], installDir);
+    try { fs.rmSync(zipPath, { force: true }); } catch {}
+    if (!okUnzip) return undefined;
+
+    if (process.platform !== "win32") {
+      try { fs.chmodSync(launcher, 0o755); } catch {}
+    }
+
+    return fs.existsSync(launcher) ? launcher : undefined;
+  } catch {
+    return undefined;
+  }
+}
+
+async function spawnKotlinLanguageServer(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
+  // Prefer JetBrains Kotlin LSP (Kotlin/kotlin-lsp) โ€“ better diagnostics for Gradle/Android projects.
+  const explicit = process.env.PI_LSP_KOTLIN_LSP_PATH;
+  if (explicit && fs.existsSync(explicit)) {
+    return spawnWithFallback(explicit, [["--stdio"]], root);
+  }
+
+  const jetbrains = which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || await ensureJetBrainsKotlinLspInstalled();
+  if (jetbrains) {
+    return spawnWithFallback(jetbrains, [["--stdio"]], root);
+  }
+
+  // Fallback: org.javacs/kotlin-language-server (often lacks diagnostics without full classpath)
+  const kls = which("kotlin-language-server");
+  if (!kls) return undefined;
+  return spawnWithFallback(kls, [[]], root);
+}
+
+async function spawnSourcekitLsp(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
+  const direct = which("sourcekit-lsp");
+  if (direct) return spawnWithFallback(direct, [[], ["--stdio"]], root);
+
+  // macOS/Xcode: sourcekit-lsp is often available via xcrun
+  const xcrun = which("xcrun");
+  if (!xcrun) return undefined;
+  return spawnWithFallback(xcrun, [["sourcekit-lsp"], ["sourcekit-lsp", "--stdio"]], root);
+}
+
+// Server Configs
+export const LSP_SERVERS: LSPServerConfig[] = [
+  {
+    id: "dart", extensions: [".dart"],
+    findRoot: (f, cwd) => findRoot(f, cwd, ["pubspec.yaml", "analysis_options.yaml"]),
+    spawn: async (root) => {
+      let dart = which("dart");
+      const pubspec = path.join(root, "pubspec.yaml");
+      if (fs.existsSync(pubspec)) {
+        try {
+          const content = fs.readFileSync(pubspec, "utf-8");
+          if (content.includes("flutter:") || content.includes("sdk: flutter")) {
+            const flutter = which("flutter");
+            if (flutter) {
+              const dir = path.dirname(fs.realpathSync(flutter));
+              for (const p of ["cache/dart-sdk/bin/dart", "../cache/dart-sdk/bin/dart"]) {
+                const c = path.join(dir, p);
+                if (fs.existsSync(c)) { dart = c; break; }
+              }
+            }
+          }
+        } catch {}
+      }
+      if (!dart) return undefined;
+      return { process: spawn(dart, ["language-server", "--protocol=lsp"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
+    },
+  },
+  {
+    id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
+    findRoot: (f, cwd) => {
+      if (findNearestFile(path.dirname(f), ["deno.json", "deno.jsonc"], cwd)) return undefined;
+      return findRoot(f, cwd, ["package.json", "tsconfig.json", "jsconfig.json"]);
+    },
+    spawn: async (root) => {
+      const local = path.join(root, "node_modules/.bin/typescript-language-server");
+      const cmd = fs.existsSync(local) ? local : which("typescript-language-server");
+      if (!cmd) return undefined;
+      return { process: spawn(cmd, ["--stdio"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
+    },
+  },
+  { id: "vue", extensions: [".vue"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "vite.config.ts", "vite.config.js"]), spawn: simpleSpawn("vue-language-server") },
+  { id: "svelte", extensions: [".svelte"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "svelte.config.js"]), spawn: simpleSpawn("svelteserver") },
+  { id: "pyright", extensions: [".py", ".pyi"], findRoot: (f, cwd) => findRoot(f, cwd, ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"]), spawn: simpleSpawn("pyright-langserver") },
+  { id: "gopls", extensions: [".go"], findRoot: (f, cwd) => findRoot(f, cwd, ["go.work"]) || findRoot(f, cwd, ["go.mod"]), spawn: simpleSpawn("gopls", []) },
+  {
+    id: "kotlin", extensions: [".kt", ".kts"],
+    findRoot: (f, cwd) => findRootKotlin(f, cwd),
+    spawn: async (root) => {
+      const proc = await spawnKotlinLanguageServer(root);
+      if (!proc) return undefined;
+      return { process: proc };
+    },
+  },
+  {
+    id: "swift", extensions: [".swift"],
+    findRoot: (f, cwd) => findRootSwift(f, cwd),
+    spawn: async (root) => {
+      const proc = await spawnSourcekitLsp(root);
+      if (!proc) return undefined;
+      return { process: proc };
+    },
+  },
+  { id: "rust-analyzer", extensions: [".rs"], findRoot: (f, cwd) => findRoot(f, cwd, ["Cargo.toml"]), spawn: simpleSpawn("rust-analyzer", []) },
+  {
+    id: "nil", extensions: [".nix"],
+    findRoot: (f, cwd) => findRoot(f, cwd, ["flake.nix", "default.nix", "shell.nix"]),
+    spawn: async (root) => {
+      // Prefer nil, fall back to nixd
+      const nil = which("nil");
+      if (nil) return { process: spawn(nil, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
+      const nixd = which("nixd");
+      if (nixd) return { process: spawn(nixd, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
+      return undefined;
+    },
+  },
+];
+
+// Singleton Manager
+let sharedManager: LSPManager | null = null;
+let managerCwd: string | null = null;
+
+export function getOrCreateManager(cwd: string): LSPManager {
+  if (!sharedManager || managerCwd !== cwd) {
+    sharedManager?.shutdown().catch(() => {});
+    sharedManager = new LSPManager(cwd);
+    managerCwd = cwd;
+  }
+  return sharedManager;
+}
+
+export function getManager(): LSPManager | null { return sharedManager; }
+
+export async function shutdownManager(): Promise<void> {
+  const manager = sharedManager;
+  if (!manager) return;
+
+  // Clear singleton pointers first so new requests never receive a manager
+  // that's currently being shut down.
+  sharedManager = null;
+  managerCwd = null;
+
+  await manager.shutdown();
+}
+
+// LSP Manager
+export class LSPManager {
+  private clients = new Map<string, LSPClient>();
+  private spawning = new Map<string, Promise<LSPClient | undefined>>();
+  private broken = new Set<string>();
+  private cwd: string;
+  private cleanupTimer: NodeJS.Timeout | null = null;
+
+  constructor(cwd: string) {
+    this.cwd = cwd;
+    this.cleanupTimer = setInterval(() => this.cleanupIdleFiles(), CLEANUP_INTERVAL_MS);
+    this.cleanupTimer.unref();
+  }
+
+  private cleanupIdleFiles() {
+    const now = Date.now();
+    for (const client of this.clients.values()) {
+      for (const [fp, state] of client.openFiles) {
+        if (now - state.lastAccess > IDLE_TIMEOUT_MS) this.closeFile(client, fp);
+      }
+    }
+  }
+
+  private closeFile(client: LSPClient, absPath: string) {
+    if (!client.openFiles.has(absPath)) return;
+    client.openFiles.delete(absPath);
+    if (client.closed) return;
+    try {
+      void client.connection.sendNotification(DidCloseTextDocumentNotification.type, {
+        textDocument: { uri: pathToFileURL(absPath).href },
+      }).catch(() => {});
+    } catch {}
+  }
+
+  private evictLRU(client: LSPClient) {
+    if (client.openFiles.size <= MAX_OPEN_FILES) return;
+    let oldest: { path: string; time: number } | null = null;
+    for (const [fp, s] of client.openFiles) {
+      if (!oldest || s.lastAccess < oldest.time) oldest = { path: fp, time: s.lastAccess };
+    }
+    if (oldest) this.closeFile(client, oldest.path);
+  }
+
+  private key(id: string, root: string) { return `${id}:${root}`; }
+
+  private async initClient(config: LSPServerConfig, root: string): Promise<LSPClient | undefined> {
+    const k = this.key(config.id, root);
+    try {
+      const handle = await config.spawn(root);
+      if (!handle) { this.broken.add(k); return undefined; }
+
+      const reader = new StreamMessageReader(handle.process.stdout!);
+      const writer = new StreamMessageWriter(handle.process.stdin!);
+      const conn = createMessageConnection(reader, writer);
+      
+      // Prevent crashes from stream errors
+      handle.process.stdin?.on("error", () => {});
+      handle.process.stdout?.on("error", () => {});
+
+      const stderr: string[] = [];
+      const MAX_STDERR_LINES = 200;
+      handle.process.stderr?.on("data", (chunk: Buffer) => {
+        try {
+          const text = chunk.toString("utf-8");
+          for (const line of text.split(/\r?\n/)) {
+            if (!line.trim()) continue;
+            stderr.push(line);
+            if (stderr.length > MAX_STDERR_LINES) stderr.splice(0, stderr.length - MAX_STDERR_LINES);
+          }
+        } catch {
+          // ignore
+        }
+      });
+      handle.process.stderr?.on("error", () => {});
+
+      const client: LSPClient = {
+        connection: conn,
+        process: handle.process,
+        diagnostics: new Map(),
+        openFiles: new Map(),
+        listeners: new Map(),
+        stderr,
+        root,
+        closed: false,
+      };
+
+      conn.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
+        const fpRaw = decodeURIComponent(new URL(params.uri).pathname);
+        const fp = normalizeFsPath(fpRaw);
+
+        client.diagnostics.set(fp, params.diagnostics);
+        // Notify both raw and normalized paths (macOS often reports /private/var vs /var)
+        const listeners1 = client.listeners.get(fp);
+        const listeners2 = fp !== fpRaw ? client.listeners.get(fpRaw) : undefined;
+
+        listeners1?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
+        listeners2?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
+      });
+
+      // Handle errors to prevent crashes
+      conn.onError(() => {});
+      conn.onClose(() => { client.closed = true; this.clients.delete(k); });
+
+      conn.onRequest("workspace/configuration", () => [handle.initOptions ?? {}]);
+      conn.onRequest("window/workDoneProgress/create", () => null);
+      conn.onRequest("client/registerCapability", () => {});
+      conn.onRequest("client/unregisterCapability", () => {});
+      conn.onRequest("workspace/workspaceFolders", () => [{ name: "workspace", uri: pathToFileURL(root).href }]);
+
+      handle.process.on("exit", () => { client.closed = true; this.clients.delete(k); });
+      handle.process.on("error", () => { client.closed = true; this.clients.delete(k); this.broken.add(k); });
+
+      conn.listen();
+
+      const initResult = await timeout(conn.sendRequest(InitializeRequest.method, {
+        rootUri: pathToFileURL(root).href,
+        rootPath: root,
+        processId: process.pid,
+        workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
+        initializationOptions: handle.initOptions ?? {},
+        capabilities: {
+          window: { workDoneProgress: true },
+          workspace: { configuration: true },
+          textDocument: {
+            synchronization: { didSave: true, didOpen: true, didChange: true, didClose: true },
+            publishDiagnostics: { versionSupport: true },
+            diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
+          },
+        },
+      }), INIT_TIMEOUT_MS, `${config.id} init`);
+
+      client.capabilities = (initResult as any)?.capabilities;
+
+      conn.sendNotification(InitializedNotification.type, {});
+      if (handle.initOptions) {
+        conn.sendNotification("workspace/didChangeConfiguration", { settings: handle.initOptions });
+      }
+      return client;
+    } catch { this.broken.add(k); return undefined; }
+  }
+
+  async getClientsForFile(filePath: string): Promise<LSPClient[]> {
+    const ext = path.extname(filePath);
+    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.cwd, filePath);
+    const clients: LSPClient[] = [];
+
+    for (const config of LSP_SERVERS) {
+      if (!config.extensions.includes(ext)) continue;
+      const root = config.findRoot(absPath, this.cwd);
+      if (!root) continue;
+      const k = this.key(config.id, root);
+      if (this.broken.has(k)) continue;
+
+      const existing = this.clients.get(k);
+      if (existing) { clients.push(existing); continue; }
+
+      if (!this.spawning.has(k)) {
+        const p = this.initClient(config, root);
+        this.spawning.set(k, p);
+        p.finally(() => this.spawning.delete(k));
+      }
+      const client = await this.spawning.get(k);
+      if (client) { this.clients.set(k, client); clients.push(client); }
+    }
+    return clients;
+  }
+
+  private resolve(fp: string) {
+    const abs = path.isAbsolute(fp) ? fp : path.resolve(this.cwd, fp);
+    return normalizeFsPath(abs);
+  }
+  private langId(fp: string) { return LANGUAGE_IDS[path.extname(fp)] || "plaintext"; }
+  private readFile(fp: string): string | null { try { return fs.readFileSync(fp, "utf-8"); } catch { return null; } }
+
+  private explainNoLsp(absPath: string): string {
+    const ext = path.extname(absPath);
+
+    if (ext === ".kt" || ext === ".kts") {
+      const root = findRootKotlin(absPath, this.cwd);
+      if (!root) return `No Kotlin project root detected (looked for settings.gradle(.kts), build.gradle(.kts), gradlew, pom.xml under cwd)`;
+
+      const hasJetbrains = !!(which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || process.env.PI_LSP_KOTLIN_LSP_PATH);
+      const hasKls = !!which("kotlin-language-server");
+
+      if (!hasJetbrains && !hasKls) {
+        return "No Kotlin LSP binary found. Install Kotlin/kotlin-lsp (recommended) or org.javacs/kotlin-language-server.";
+      }
+
+      const k = this.key("kotlin", root);
+      if (this.broken.has(k)) return `Kotlin LSP failed to initialize for root: ${root}`;
+
+      if (!hasJetbrains && hasKls) {
+        return "Kotlin LSP is running via kotlin-language-server, but that server often does not produce diagnostics for Gradle/Android projects. Prefer Kotlin/kotlin-lsp.";
+      }
+
+      return `Kotlin LSP unavailable for root: ${root}`;
+    }
+
+    if (ext === ".swift") {
+      const root = findRootSwift(absPath, this.cwd);
+      if (!root) return `No Swift project root detected (looked for Package.swift, *.xcodeproj, *.xcworkspace under cwd)`;
+      if (!which("sourcekit-lsp") && !which("xcrun")) return "sourcekit-lsp not found (and xcrun missing)";
+      const k = this.key("swift", root);
+      if (this.broken.has(k)) return `sourcekit-lsp failed to initialize for root: ${root}`;
+      return `Swift LSP unavailable for root: ${root}`;
+    }
+
+    return `No LSP for ${ext}`;
+  }
+
+  private toPos(line: number, col: number) { return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) }; }
+
+  private normalizeLocs(result: Location | Location[] | LocationLink[] | null | undefined): Location[] {
+    if (!result) return [];
+    const items = Array.isArray(result) ? result : [result];
+    if (!items.length) return [];
+    if ("uri" in items[0] && "range" in items[0]) return items as Location[];
+    return (items as LocationLink[]).map(l => ({ uri: l.targetUri, range: l.targetSelectionRange ?? l.targetRange }));
+  }
+
+  private normalizeSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): DocumentSymbol[] {
+    if (!result?.length) return [];
+    const first = result[0];
+    if ("location" in first) {
+      return (result as SymbolInformation[]).map(s => ({
+        name: s.name, kind: s.kind, range: s.location.range, selectionRange: s.location.range,
+        detail: s.containerName, tags: s.tags, deprecated: s.deprecated, children: [],
+      }));
+    }
+    return result as DocumentSymbol[];
+  }
+
+  private async openOrUpdate(clients: LSPClient[], absPath: string, uri: string, langId: string, content: string, evict = true) {
+    const now = Date.now();
+    for (const client of clients) {
+      if (client.closed) continue;
+      const state = client.openFiles.get(absPath);
+      try {
+        if (state) {
+          const v = state.version + 1;
+          client.openFiles.set(absPath, { version: v, lastAccess: now });
+          void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
+            textDocument: { uri, version: v }, contentChanges: [{ text: content }],
+          }).catch(() => {});
+        } else {
+          // For some servers (e.g. kotlin-language-server), diagnostics only start flowing after a didChange.
+          // We open at version 0, then immediately send a full-content didChange at version 1.
+          client.openFiles.set(absPath, { version: 1, lastAccess: now });
+          void client.connection.sendNotification(DidOpenTextDocumentNotification.type, {
+            textDocument: { uri, languageId: langId, version: 0, text: content },
+          }).catch(() => {});
+          void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
+            textDocument: { uri, version: 1 }, contentChanges: [{ text: content }],
+          }).catch(() => {});
+          if (evict) this.evictLRU(client);
+        }
+        // Send didSave to trigger analysis (important for TypeScript)
+        void client.connection.sendNotification(DidSaveTextDocumentNotification.type, {
+          textDocument: { uri }, text: content,
+        }).catch(() => {});
+      } catch {}
+    }
+  }
+
+  private async loadFile(filePath: string) {
+    const absPath = this.resolve(filePath);
+    const clients = await this.getClientsForFile(absPath);
+    if (!clients.length) return null;
+    const content = this.readFile(absPath);
+    if (content === null) return null;
+    return { clients, absPath, uri: pathToFileURL(absPath).href, langId: this.langId(absPath), content };
+  }
+
+  private waitForDiagnostics(client: LSPClient, absPath: string, timeoutMs: number, isNew: boolean): Promise<boolean> {
+    return new Promise(resolve => {
+      if (client.closed) return resolve(false);
+
+      let resolved = false;
+      let settleTimer: NodeJS.Timeout | null = null;
+      let listener: () => void = () => {};
+
+      const cleanupListener = () => {
+        const listeners = client.listeners.get(absPath);
+        if (!listeners) return;
+        const idx = listeners.indexOf(listener);
+        if (idx !== -1) listeners.splice(idx, 1);
+        if (listeners.length === 0) client.listeners.delete(absPath);
+      };
+
+      const finish = (value: boolean) => {
+        if (resolved) return;
+        resolved = true;
+        if (settleTimer) clearTimeout(settleTimer);
+        clearTimeout(timer);
+        cleanupListener();
+        resolve(value);
+      };
+
+      // Some servers publish diagnostics multiple times (often empty first, then real results).
+      // For new documents, if diagnostics are still empty, debounce a bit.
+      listener = () => {
+        if (resolved) return;
+
+        const current = client.diagnostics.get(absPath);
+        if (current && current.length > 0) return finish(true);
+
+        if (!isNew) return finish(true);
+
+        if (settleTimer) clearTimeout(settleTimer);
+        settleTimer = setTimeout(() => finish(true), 2500);
+        (settleTimer as any).unref?.();
+      };
+
+      const timer = setTimeout(() => finish(false), timeoutMs);
+      (timer as any).unref?.();
+
+      const listeners = client.listeners.get(absPath) || [];
+      listeners.push(listener);
+      client.listeners.set(absPath, listeners);
+    });
+  }
+
+  private async pullDiagnostics(client: LSPClient, absPath: string, uri: string): Promise<{ diagnostics: Diagnostic[]; responded: boolean }> {
+    if (client.closed) return { diagnostics: [], responded: false };
+
+    // Only attempt Pull Diagnostics if the server advertises support.
+    // (Some servers throw and log noisy errors if we call these methods.)
+    if (!client.capabilities || !(client.capabilities as any).diagnosticProvider) {
+      return { diagnostics: [], responded: false };
+    }
+
+    // Prefer new Pull Diagnostics if supported by the server
+    try {
+      const res: any = await client.connection.sendRequest(DocumentDiagnosticRequest.method, {
+        textDocument: { uri },
+      });
+
+      if (res?.kind === DocumentDiagnosticReportKind.Full) {
+        return { diagnostics: Array.isArray(res.items) ? res.items : [], responded: true };
+      }
+      if (res?.kind === DocumentDiagnosticReportKind.Unchanged) {
+        return { diagnostics: client.diagnostics.get(absPath) || [], responded: true };
+      }
+      if (Array.isArray(res?.items)) {
+        return { diagnostics: res.items, responded: true };
+      }
+      return { diagnostics: [], responded: true };
+    } catch {
+      // ignore
+    }
+
+    // Fallback: some servers only support WorkspaceDiagnosticRequest
+    try {
+      const res: any = await client.connection.sendRequest(WorkspaceDiagnosticRequest.method, {
+        previousResultIds: [],
+      });
+
+      const items: any[] = res?.items || [];
+      const match = items.find((it: any) => it?.uri === uri);
+      if (match?.kind === DocumentDiagnosticReportKind.Full) {
+        return { diagnostics: Array.isArray(match.items) ? match.items : [], responded: true };
+      }
+      if (Array.isArray(match?.items)) {
+        return { diagnostics: match.items, responded: true };
+      }
+      return { diagnostics: [], responded: true };
+    } catch {
+      return { diagnostics: [], responded: false };
+    }
+  }
+
+  async touchFileAndWait(filePath: string, timeoutMs: number): Promise<{ diagnostics: Diagnostic[]; receivedResponse: boolean; unsupported?: boolean; error?: string }> {
+    const absPath = this.resolve(filePath);
+
+    if (!fs.existsSync(absPath)) {
+      return { diagnostics: [], receivedResponse: false, unsupported: true, error: "File not found" };
+    }
+
+    const clients = await this.getClientsForFile(absPath);
+    if (!clients.length) {
+      return { diagnostics: [], receivedResponse: false, unsupported: true, error: this.explainNoLsp(absPath) };
+    }
+
+    const content = this.readFile(absPath);
+    if (content === null) {
+      return { diagnostics: [], receivedResponse: false, unsupported: true, error: "Could not read file" };
+    }
+
+    const uri = pathToFileURL(absPath).href;
+    const langId = this.langId(absPath);
+    const isNew = clients.some(c => !c.openFiles.has(absPath));
+
+    const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
+    await this.openOrUpdate(clients, absPath, uri, langId, content);
+    const results = await Promise.all(waits);
+
+    let responded = results.some(r => r);
+    const diags: Diagnostic[] = [];
+    for (const c of clients) {
+      const d = c.diagnostics.get(absPath);
+      if (d) diags.push(...d);
+    }
+    if (!responded && clients.some(c => c.diagnostics.has(absPath))) responded = true;
+
+    // If we didn't get pushed diagnostics (common for some servers), try pull diagnostics.
+    if (!responded || diags.length === 0) {
+      const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
+      for (let i = 0; i < clients.length; i++) {
+        const r = pulled[i];
+        if (r.responded) responded = true;
+        if (r.diagnostics.length) {
+          clients[i].diagnostics.set(absPath, r.diagnostics);
+          diags.push(...r.diagnostics);
+        }
+      }
+    }
+
+    return { diagnostics: diags, receivedResponse: responded };
+  }
+
+  async getDiagnosticsForFiles(files: string[], timeoutMs: number): Promise<FileDiagnosticsResult> {
+    const unique = [...new Set(files.map(f => this.resolve(f)))];
+    const results: FileDiagnosticItem[] = [];
+    const toClose: Map<LSPClient, string[]> = new Map();
+
+    for (const absPath of unique) {
+      if (!fs.existsSync(absPath)) {
+        results.push({ file: absPath, diagnostics: [], status: 'error', error: 'File not found' });
+        continue;
+      }
+
+      let clients: LSPClient[];
+      try { clients = await this.getClientsForFile(absPath); }
+      catch (e) { results.push({ file: absPath, diagnostics: [], status: 'error', error: String(e) }); continue; }
+
+      if (!clients.length) {
+        results.push({ file: absPath, diagnostics: [], status: 'unsupported', error: this.explainNoLsp(absPath) });
+        continue;
+      }
+
+      const content = this.readFile(absPath);
+      if (!content) {
+        results.push({ file: absPath, diagnostics: [], status: 'error', error: 'Could not read file' });
+        continue;
+      }
+
+      const uri = pathToFileURL(absPath).href;
+      const langId = this.langId(absPath);
+      const isNew = clients.some(c => !c.openFiles.has(absPath));
+
+      for (const c of clients) {
+        if (!c.openFiles.has(absPath)) {
+          if (!toClose.has(c)) toClose.set(c, []);
+          toClose.get(c)!.push(absPath);
+        }
+      }
+
+      const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
+      await this.openOrUpdate(clients, absPath, uri, langId, content, false);
+      const waitResults = await Promise.all(waits);
+
+      const diags: Diagnostic[] = [];
+      for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); }
+
+      let responded = waitResults.some(r => r) || diags.length > 0;
+
+      if (!responded || diags.length === 0) {
+        const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
+        for (let i = 0; i < clients.length; i++) {
+          const r = pulled[i];
+          if (r.responded) responded = true;
+          if (r.diagnostics.length) {
+            clients[i].diagnostics.set(absPath, r.diagnostics);
+            diags.push(...r.diagnostics);
+          }
+        }
+      }
+
+      if (!responded && !diags.length) {
+        results.push({ file: absPath, diagnostics: [], status: 'timeout', error: 'LSP did not respond' });
+      } else {
+        results.push({ file: absPath, diagnostics: diags, status: 'ok' });
+      }
+    }
+
+    // Cleanup opened files
+    for (const [c, fps] of toClose) { for (const fp of fps) this.closeFile(c, fp); }
+    for (const c of this.clients.values()) { while (c.openFiles.size > MAX_OPEN_FILES) this.evictLRU(c); }
+
+    return { items: results };
+  }
+
+  async getDefinition(fp: string, line: number, col: number): Promise<Location[]> {
+    const l = await this.loadFile(fp);
+    if (!l) return [];
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const pos = this.toPos(line, col);
+    const results = await Promise.all(l.clients.map(async c => {
+      if (c.closed) return [];
+      try { return this.normalizeLocs(await c.connection.sendRequest(DefinitionRequest.type, { textDocument: { uri: l.uri }, position: pos })); }
+      catch { return []; }
+    }));
+    return results.flat();
+  }
+
+  async getReferences(fp: string, line: number, col: number): Promise<Location[]> {
+    const l = await this.loadFile(fp);
+    if (!l) return [];
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const pos = this.toPos(line, col);
+    const results = await Promise.all(l.clients.map(async c => {
+      if (c.closed) return [];
+      try { return this.normalizeLocs(await c.connection.sendRequest(ReferencesRequest.type, { textDocument: { uri: l.uri }, position: pos, context: { includeDeclaration: true } })); }
+      catch { return []; }
+    }));
+    return results.flat();
+  }
+
+  async getHover(fp: string, line: number, col: number): Promise<Hover | null> {
+    const l = await this.loadFile(fp);
+    if (!l) return null;
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const pos = this.toPos(line, col);
+    for (const c of l.clients) {
+      if (c.closed) continue;
+      try { const r = await c.connection.sendRequest(HoverRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
+      catch {}
+    }
+    return null;
+  }
+
+  async getSignatureHelp(fp: string, line: number, col: number): Promise<SignatureHelp | null> {
+    const l = await this.loadFile(fp);
+    if (!l) return null;
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const pos = this.toPos(line, col);
+    for (const c of l.clients) {
+      if (c.closed) continue;
+      try { const r = await c.connection.sendRequest(SignatureHelpRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
+      catch {}
+    }
+    return null;
+  }
+
+  async getDocumentSymbols(fp: string): Promise<DocumentSymbol[]> {
+    const l = await this.loadFile(fp);
+    if (!l) return [];
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const results = await Promise.all(l.clients.map(async c => {
+      if (c.closed) return [];
+      try { return this.normalizeSymbols(await c.connection.sendRequest(DocumentSymbolRequest.type, { textDocument: { uri: l.uri } })); }
+      catch { return []; }
+    }));
+    return results.flat();
+  }
+
+  async rename(fp: string, line: number, col: number, newName: string): Promise<WorkspaceEdit | null> {
+    const l = await this.loadFile(fp);
+    if (!l) return null;
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    const pos = this.toPos(line, col);
+    for (const c of l.clients) {
+      if (c.closed) continue;
+      try {
+        const r = await c.connection.sendRequest(RenameRequest.type, {
+          textDocument: { uri: l.uri },
+          position: pos,
+          newName,
+        });
+        if (r) return r;
+      } catch {}
+    }
+    return null;
+  }
+
+  async getCodeActions(fp: string, startLine: number, startCol: number, endLine?: number, endCol?: number): Promise<(CodeAction | Command)[]> {
+    const l = await this.loadFile(fp);
+    if (!l) return [];
+    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
+    
+    const start = this.toPos(startLine, startCol);
+    const end = this.toPos(endLine ?? startLine, endCol ?? startCol);
+    const range = { start, end };
+    
+    // Get diagnostics for this range to include in context
+    const diagnostics: Diagnostic[] = [];
+    for (const c of l.clients) {
+      const fileDiags = c.diagnostics.get(l.absPath) || [];
+      for (const d of fileDiags) {
+        if (this.rangesOverlap(d.range, range)) diagnostics.push(d);
+      }
+    }
+    
+    const results = await Promise.all(l.clients.map(async c => {
+      if (c.closed) return [];
+      try {
+        const r = await c.connection.sendRequest(CodeActionRequest.type, {
+          textDocument: { uri: l.uri },
+          range,
+          context: { diagnostics, only: [CodeActionKind.QuickFix, CodeActionKind.Refactor, CodeActionKind.Source] },
+        });
+        return r || [];
+      } catch { return []; }
+    }));
+    return results.flat();
+  }
+
+  private rangesOverlap(a: { start: { line: number; character: number }; end: { line: number; character: number } }, 
+                        b: { start: { line: number; character: number }; end: { line: number; character: number } }): boolean {
+    if (a.end.line < b.start.line || b.end.line < a.start.line) return false;
+    if (a.end.line === b.start.line && a.end.character < b.start.character) return false;
+    if (b.end.line === a.start.line && b.end.character < a.start.character) return false;
+    return true;
+  }
+
+  async shutdown() {
+    if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; }
+    const clients = Array.from(this.clients.values());
+    this.clients.clear();
+    for (const c of clients) {
+      const wasClosed = c.closed;
+      c.closed = true;
+      if (!wasClosed) {
+        try {
+          await Promise.race([
+            c.connection.sendRequest("shutdown"),
+            new Promise(r => setTimeout(r, 1000))
+          ]);
+        } catch {}
+        try { void c.connection.sendNotification("exit").catch(() => {}); } catch {}
+      }
+      try { c.connection.end(); } catch {}
+      try { c.process.kill(); } catch {}
+    }
+  }
+}
+
+// Diagnostic Formatting
+export { DiagnosticSeverity };
+export type SeverityFilter = "all" | "error" | "warning" | "info" | "hint";
+
+export function formatDiagnostic(d: Diagnostic): string {
+  const sev = ["", "ERROR", "WARN", "INFO", "HINT"][d.severity || 1];
+  return `${sev} [${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}`;
+}
+
+export function filterDiagnosticsBySeverity(diags: Diagnostic[], filter: SeverityFilter): Diagnostic[] {
+  if (filter === "all") return diags;
+  const max = { error: 1, warning: 2, info: 3, hint: 4 }[filter];
+  return diags.filter(d => (d.severity || 1) <= max);
+}
+
+// URI utilities
+export function uriToPath(uri: string): string {
+  if (uri.startsWith("file://")) try { return fileURLToPath(uri); } catch {}
+  return uri;
+}
+
+// Symbol search
+export function findSymbolPosition(symbols: DocumentSymbol[], query: string): { line: number; character: number } | null {
+  const q = query.toLowerCase();
+  let exact: { line: number; character: number } | null = null;
+  let partial: { line: number; character: number } | null = null;
+
+  const visit = (items: DocumentSymbol[]) => {
+    for (const sym of items) {
+      const name = String(sym?.name ?? "").toLowerCase();
+      const pos = sym?.selectionRange?.start ?? sym?.range?.start;
+      if (pos && typeof pos.line === "number" && typeof pos.character === "number") {
+        if (!exact && name === q) exact = pos;
+        if (!partial && name.includes(q)) partial = pos;
+      }
+      if (sym?.children?.length) visit(sym.children);
+    }
+  };
+  visit(symbols);
+  return exact ?? partial;
+}
+
+export async function resolvePosition(manager: LSPManager, file: string, query: string): Promise<{ line: number; column: number } | null> {
+  const symbols = await manager.getDocumentSymbols(file);
+  const pos = findSymbolPosition(symbols, query);
+  return pos ? { line: pos.line + 1, column: pos.character + 1 } : null;
+}
dots/pi/agent/extensions/lsp/lsp-tool.ts
@@ -0,0 +1,382 @@
+/**
+ * LSP Tool Extension for pi-coding-agent
+ *
+ * Provides Language Server Protocol tool for:
+ * - definitions, references, hover, signature help
+ * - document symbols, diagnostics, workspace diagnostics
+ * - rename, code actions
+ *
+ * Supported languages:
+ *   - Dart/Flutter (dart language-server)
+ *   - TypeScript/JavaScript (typescript-language-server)
+ *   - Vue (vue-language-server)
+ *   - Svelte (svelteserver)
+ *   - Python (pyright-langserver)
+ *   - Go (gopls)
+ *   - Kotlin (kotlin-ls)
+ *   - Swift (sourcekit-lsp)
+ *   - Rust (rust-analyzer)
+ *
+ * Usage:
+ *   pi --extension ./lsp-tool.ts
+ *
+ * Or use the combined lsp.ts extension for both hook and tool functionality.
+ */
+
+import * as path from "node:path";
+import { Type, type Static } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
+
+const PREVIEW_LINES = 10;
+
+const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
+
+function diagnosticsWaitMsForFile(filePath: string): number {
+  const ext = path.extname(filePath).toLowerCase();
+  if (ext === ".kt" || ext === ".kts") return 30000;
+  if (ext === ".swift") return 20000;
+  if (ext === ".rs") return 20000;
+  return DIAGNOSTICS_WAIT_MS_DEFAULT;
+}
+
+const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const;
+const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
+
+const LspParams = Type.Object({
+  action: StringEnum(ACTIONS),
+  file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
+  files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
+  line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
+  column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
+  endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
+  endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
+  query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
+  newName: Type.Optional(Type.String({ description: "New name for rename action" })),
+  severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
+});
+
+type LspParamsType = Static<typeof LspParams>;
+
+function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
+  if (!signal) return promise;
+  if (signal.aborted) return Promise.reject(new Error("aborted"));
+
+  return new Promise<T>((resolve, reject) => {
+    const onAbort = () => {
+      cleanup();
+      reject(new Error("aborted"));
+    };
+
+    const cleanup = () => {
+      signal.removeEventListener("abort", onAbort);
+    };
+
+    signal.addEventListener("abort", onAbort, { once: true });
+
+    promise.then(
+      (value) => {
+        cleanup();
+        resolve(value);
+      },
+      (err) => {
+        cleanup();
+        reject(err);
+      },
+    );
+  });
+}
+
+function isAbortedError(e: unknown): boolean {
+  return e instanceof Error && e.message === "aborted";
+}
+
+function cancelledToolResult() {
+  return {
+    content: [{ type: "text" as const, text: "Cancelled" }],
+    details: { cancelled: true },
+  };
+}
+
+type ExecuteArgs = {
+  signal: AbortSignal | undefined;
+  onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }) => void) | undefined;
+  ctx: { cwd: string };
+};
+
+function isAbortSignalLike(value: unknown): value is AbortSignal {
+  return !!value
+    && typeof value === "object"
+    && "aborted" in value
+    && typeof (value as any).aborted === "boolean"
+    && typeof (value as any).addEventListener === "function";
+}
+
+function isContextLike(value: unknown): value is { cwd: string } {
+  return !!value && typeof value === "object" && typeof (value as any).cwd === "string";
+}
+
+function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs {
+  // Runtime >= 0.51: (signal, onUpdate, ctx)
+  if (isContextLike(signalArg)) {
+    return {
+      signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined,
+      onUpdate: typeof ctxArg === "function" ? ctxArg as ExecuteArgs["onUpdate"] : undefined,
+      ctx: signalArg,
+    };
+  }
+
+  // Runtime <= 0.50: (onUpdate, ctx, signal)
+  if (isContextLike(ctxArg)) {
+    return {
+      signal: isAbortSignalLike(signalArg) ? signalArg : undefined,
+      onUpdate: typeof onUpdateArg === "function" ? onUpdateArg as ExecuteArgs["onUpdate"] : undefined,
+      ctx: ctxArg,
+    };
+  }
+
+  throw new Error("Invalid tool execution context");
+}
+
+function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
+  const abs = uriToPath(loc.uri);
+  const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
+  const { line, character: col } = loc.range?.start ?? {};
+  return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
+}
+
+function formatHover(contents: unknown): string {
+  if (typeof contents === "string") return contents;
+  if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n");
+  if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value);
+  return "";
+}
+
+function formatSignature(help: any): string {
+  if (!help?.signatures?.length) return "No signature help available.";
+  const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
+  let text = sig.label ?? "Signature";
+  if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
+  if (sig.parameters?.length) {
+    const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean);
+    if (params.length) text += `\nParameters: ${params.join(", ")}`;
+  }
+  return text;
+}
+
+function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] {
+  for (const sym of symbols) {
+    const name = sym?.name ?? "<unknown>";
+    if (query && !name.toLowerCase().includes(query.toLowerCase())) {
+      if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
+      continue;
+    }
+    const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
+    lines.push(`${"  ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
+    if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
+  }
+  return lines;
+}
+
+function formatWorkspaceEdit(edit: any, cwd?: string): string {
+  const lines: string[] = [];
+  
+  if (edit.documentChanges?.length) {
+    for (const change of edit.documentChanges) {
+      if (change.textDocument?.uri) {
+        const fp = uriToPath(change.textDocument.uri);
+        const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
+        lines.push(`${display}:`);
+        for (const e of change.edits || []) {
+          const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
+          lines.push(`  [${loc}] โ†’ "${e.newText}"`);
+        }
+      }
+    }
+  }
+  
+  if (edit.changes) {
+    for (const [uri, edits] of Object.entries(edit.changes)) {
+      const fp = uriToPath(uri);
+      const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
+      lines.push(`${display}:`);
+      for (const e of edits as any[]) {
+        const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
+        lines.push(`  [${loc}] โ†’ "${e.newText}"`);
+      }
+    }
+  }
+  
+  return lines.length ? lines.join("\n") : "No edits.";
+}
+
+function formatCodeActions(actions: any[]): string[] {
+  return actions.map((a, i) => {
+    const title = a.title || a.command?.title || "Untitled action";
+    const kind = a.kind ? ` (${a.kind})` : "";
+    const isPreferred = a.isPreferred ? " โ˜…" : "";
+    return `${i + 1}. ${title}${kind}${isPreferred}`;
+  });
+}
+
+export default function (pi: ExtensionAPI) {
+  pi.registerTool({
+    name: "lsp",
+    label: "LSP",
+    description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
+
+Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position).
+Use bash to find files: find src -name "*.ts" -type f`,
+    parameters: LspParams,
+
+    async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) {
+      const { signal, onUpdate, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg);
+      if (signal?.aborted) return cancelledToolResult();
+      if (onUpdate) {
+        onUpdate({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } });
+      }
+
+      const manager = getOrCreateManager(ctx.cwd);
+      const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
+      const sevFilter: SeverityFilter = severity || "all";
+      const needsFile = action !== "workspace-diagnostics";
+      const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
+
+      try {
+        if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
+
+        let rLine = line, rCol = column, fromQuery = false;
+        if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
+          const resolved = await abortable(resolvePosition(manager, file, query), signal);
+          if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; }
+        }
+        if (needsPos && (rLine === undefined || rCol === undefined)) {
+          throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
+        }
+
+        const qLine = query ? `query: ${query}\n` : "";
+        const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
+        const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
+
+        switch (action) {
+          case "definition": {
+            const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
+            const locs = results.map(l => formatLocation(l, ctx?.cwd));
+            const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
+            return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
+          }
+          case "references": {
+            const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
+            const locs = results.map(l => formatLocation(l, ctx?.cwd));
+            return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
+          }
+          case "hover": {
+            const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
+            const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
+            return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
+          }
+          case "symbols": {
+            const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
+            const lines = collectSymbols(symbols, 0, [], query);
+            const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
+            return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
+          }
+          case "diagnostics": {
+            const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
+            const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
+            const payload = (result as any).unsupported
+              ? `Unsupported: ${(result as any).error || "No LSP for this file."}`
+              : !result.receivedResponse
+                ? "Timeout: LSP server did not respond. Try again."
+                : filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics.";
+            return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
+          }
+          case "workspace-diagnostics": {
+            if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
+            const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
+            const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
+            const out: string[] = [];
+            let errors = 0, warnings = 0, filesWithIssues = 0;
+
+            for (const item of result.items) {
+              const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
+              if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; }
+              const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
+              if (filtered.length) {
+                filesWithIssues++;
+                out.push(`${display}:`);
+                for (const d of filtered) {
+                  if (d.severity === 1) errors++; else if (d.severity === 2) warnings++;
+                  out.push(`  ${formatDiagnostic(d)}`);
+                }
+              }
+            }
+
+            const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
+            return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
+          }
+          case "signature": {
+            const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
+            return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
+          }
+          case "rename": {
+            if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
+            const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
+            if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
+            const edits = formatWorkspaceEdit(result, ctx?.cwd);
+            return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
+          }
+          case "codeAction": {
+            const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
+            const actions = formatCodeActions(result);
+            return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
+          }
+        }
+      } catch (e) {
+        if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
+        throw e;
+      }
+    },
+
+    renderCall(args, theme) {
+      const params = args as LspParamsType;
+      let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
+      if (params.file) text += " " + theme.fg("muted", params.file);
+      else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
+      if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
+      else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
+      if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
+      return new Text(text, 0, 0);
+    },
+
+    renderResult(result, options, theme) {
+      if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
+
+      const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || "";
+      const lines = textContent.split("\n");
+
+      let headerEnd = 0;
+      for (let i = 0; i < lines.length; i++) {
+        if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
+        else break;
+      }
+
+      const header = lines.slice(0, headerEnd);
+      const content = lines.slice(headerEnd);
+      const maxLines = options.expanded ? content.length : PREVIEW_LINES;
+      const display = content.slice(0, maxLines);
+      const remaining = content.length - maxLines;
+
+      let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
+      if (display.length) {
+        if (out) out += "\n";
+        out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
+      }
+      if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
+
+      return new Text(out, 0, 0);
+    },
+  });
+}
dots/pi/agent/extensions/lsp/lsp.ts
@@ -0,0 +1,604 @@
+/**
+ * LSP Hook Extension for pi-coding-agent
+ *
+ * Provides automatic diagnostics feedback (default: agent end).
+ * Can run after each write/edit or once per agent response.
+ *
+ * Usage:
+ *   pi --extension ./lsp.ts
+ *
+ * Or load the directory to get both hook and tool:
+ *   pi --extension ./lsp/
+ */
+
+import * as path from "node:path";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { type Diagnostic } from "vscode-languageserver-protocol";
+import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js";
+
+type HookScope = "session" | "global";
+type HookMode = "edit_write" | "agent_end" | "disabled";
+
+const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
+
+function diagnosticsWaitMsForFile(filePath: string): number {
+  const ext = path.extname(filePath).toLowerCase();
+  if (ext === ".kt" || ext === ".kts") return 30000;
+  if (ext === ".swift") return 20000;
+  if (ext === ".rs") return 20000;
+  return DIAGNOSTICS_WAIT_MS_DEFAULT;
+}
+const DIAGNOSTICS_PREVIEW_LINES = 10;
+const LSP_IDLE_SHUTDOWN_MS = 2 * 60 * 1000;
+const DIM = "\x1b[2m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
+const DEFAULT_HOOK_MODE: HookMode = "agent_end";
+const SETTINGS_NAMESPACE = "lsp";
+const LSP_CONFIG_ENTRY = "lsp-hook-config";
+
+const WARMUP_MAP: Record<string, string> = {
+  "pubspec.yaml": ".dart",
+  "package.json": ".ts",
+  "pyproject.toml": ".py",
+  "go.mod": ".go",
+  "Cargo.toml": ".rs",
+  "settings.gradle": ".kt",
+  "settings.gradle.kts": ".kt",
+  "build.gradle": ".kt",
+  "build.gradle.kts": ".kt",
+  "pom.xml": ".kt",
+  "gradlew": ".kt",
+  "gradle.properties": ".kt",
+  "Package.swift": ".swift",
+};
+
+const MODE_LABELS: Record<HookMode, string> = {
+  edit_write: "After each edit/write",
+  agent_end: "At agent end",
+  disabled: "Disabled",
+};
+
+function normalizeHookMode(value: unknown): HookMode | undefined {
+  if (value === "edit_write" || value === "agent_end" || value === "disabled") return value;
+  if (value === "turn_end") return "agent_end";
+  return undefined;
+}
+
+interface HookConfigEntry {
+  scope: HookScope;
+  hookMode?: HookMode;
+}
+
+export default function (pi: ExtensionAPI) {
+  type LspActivity = "idle" | "loading" | "working";
+
+  let activeClients: Set<string> = new Set();
+  let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null;
+  let hookMode: HookMode = DEFAULT_HOOK_MODE;
+  let hookScope: HookScope = "global";
+  let activity: LspActivity = "idle";
+  let diagnosticsAbort: AbortController | null = null;
+  let shuttingDown = false;
+  let idleShutdownTimer: NodeJS.Timeout | null = null;
+
+  const touchedFiles: Map<string, boolean> = new Map();
+  const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
+
+  function readSettingsFile(filePath: string): Record<string, unknown> {
+    try {
+      if (!fs.existsSync(filePath)) return {};
+      const raw = fs.readFileSync(filePath, "utf-8");
+      const parsed = JSON.parse(raw);
+      return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
+    } catch {
+      return {};
+    }
+  }
+
+  function getGlobalHookMode(): HookMode | undefined {
+    const settings = readSettingsFile(globalSettingsPath);
+    const lspSettings = settings[SETTINGS_NAMESPACE];
+    const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode;
+    const normalized = normalizeHookMode(hookValue);
+    if (normalized) return normalized;
+
+    const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled;
+    if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled";
+    return undefined;
+  }
+
+  function setGlobalHookMode(mode: HookMode): boolean {
+    try {
+      const settings = readSettingsFile(globalSettingsPath);
+      const existing = settings[SETTINGS_NAMESPACE];
+      const nextNamespace = (existing && typeof existing === "object")
+        ? { ...(existing as Record<string, unknown>), hookMode: mode }
+        : { hookMode: mode };
+
+      settings[SETTINGS_NAMESPACE] = nextNamespace;
+      fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
+      fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined {
+    const branchEntries = ctx.sessionManager.getBranch();
+    let latest: HookConfigEntry | undefined;
+
+    for (const entry of branchEntries) {
+      if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) {
+        latest = entry.data as HookConfigEntry | undefined;
+      }
+    }
+
+    return latest;
+  }
+
+  function restoreHookState(ctx: ExtensionContext): void {
+    const entry = getLastHookEntry(ctx);
+    if (entry?.scope === "session") {
+      const normalized = normalizeHookMode(entry.hookMode);
+      if (normalized) {
+        hookMode = normalized;
+        hookScope = "session";
+        return;
+      }
+
+      const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled;
+      if (typeof legacyEnabled === "boolean") {
+        hookMode = legacyEnabled ? "edit_write" : "disabled";
+        hookScope = "session";
+        return;
+      }
+    }
+
+    const globalSetting = getGlobalHookMode();
+    hookMode = globalSetting ?? DEFAULT_HOOK_MODE;
+    hookScope = "global";
+  }
+
+  function persistHookEntry(entry: HookConfigEntry): void {
+    pi.appendEntry<HookConfigEntry>(LSP_CONFIG_ENTRY, entry);
+  }
+
+  function labelForMode(mode: HookMode): string {
+    return MODE_LABELS[mode];
+  }
+
+  function messageContentToText(content: unknown): string {
+    if (typeof content === "string") return content;
+    if (Array.isArray(content)) {
+      return content
+        .map((item) => (item && typeof item === "object" && "type" in item && (item as any).type === "text")
+          ? String((item as any).text ?? "")
+          : "")
+        .filter(Boolean)
+        .join("\n");
+    }
+    return "";
+  }
+
+  function formatDiagnosticsForDisplay(text: string): string {
+    return text
+      .replace(/\n?This file has errors, please fix\n/gi, "\n")
+      .replace(/<\/?file_diagnostics>\n?/gi, "")
+      .replace(/\n{3,}/g, "\n\n")
+      .trim();
+  }
+
+  function setActivity(next: LspActivity): void {
+    activity = next;
+    updateLspStatus();
+  }
+
+  function clearIdleShutdownTimer(): void {
+    if (!idleShutdownTimer) return;
+    clearTimeout(idleShutdownTimer);
+    idleShutdownTimer = null;
+  }
+
+  async function shutdownLspServersForIdle(): Promise<void> {
+    diagnosticsAbort?.abort();
+    diagnosticsAbort = null;
+    setActivity("idle");
+
+    await shutdownManager();
+    activeClients.clear();
+    updateLspStatus();
+  }
+
+  function scheduleIdleShutdown(): void {
+    clearIdleShutdownTimer();
+
+    idleShutdownTimer = setTimeout(() => {
+      idleShutdownTimer = null;
+      if (shuttingDown) return;
+      void shutdownLspServersForIdle();
+    }, LSP_IDLE_SHUTDOWN_MS);
+
+    (idleShutdownTimer as any).unref?.();
+  }
+
+  function updateLspStatus(): void {
+    if (!statusUpdateFn) return;
+
+    const clients = activeClients.size > 0 ? [...activeClients].join(", ") : "";
+    const clientsText = clients ? `${DIM}${clients}${RESET}` : "";
+    const activityHint = activity === "idle" ? "" : `${DIM}โ€ข${RESET}`;
+
+    if (hookMode === "disabled") {
+      const text = clientsText
+        ? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}`
+        : `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`;
+      statusUpdateFn("lsp", text);
+      return;
+    }
+
+    let text = `${GREEN}LSP${RESET}`;
+    if (activityHint) text += ` ${activityHint}`;
+    if (clientsText) text += ` ${clientsText}`;
+    statusUpdateFn("lsp", text);
+  }
+
+  function normalizeFilePath(filePath: string, cwd: string): string {
+    return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
+  }
+
+  pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => {
+    const content = formatDiagnosticsForDisplay(messageContentToText(message.content));
+    if (!content) return new Text("", 0, 0);
+
+    const expanded = options.expanded === true;
+    const lines = content.split("\n");
+    const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES;
+    const display = lines.slice(0, maxLines);
+    const remaining = lines.length - display.length;
+
+    const styledLines = display.map((line) => {
+      if (line.startsWith("File: ")) return theme.fg("muted", line);
+      return theme.fg("toolOutput", line);
+    });
+
+    if (!expanded && remaining > 0) {
+      styledLines.push(theme.fg("dim", `... (${remaining} more lines)`));
+    }
+
+    return new Text(styledLines.join("\n"), 0, 0);
+  });
+
+  function getServerConfig(filePath: string) {
+    const ext = path.extname(filePath);
+    return LSP_SERVERS.find((s) => s.extensions.includes(ext));
+  }
+
+  function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined {
+    const absPath = normalizeFilePath(filePath, cwd);
+    const cfg = getServerConfig(absPath);
+    if (!cfg) return undefined;
+
+    if (!activeClients.has(cfg.id)) {
+      activeClients.add(cfg.id);
+      updateLspStatus();
+    }
+
+    return absPath;
+  }
+
+  function extractLspFiles(input: Record<string, unknown>): string[] {
+    const files: string[] = [];
+
+    if (typeof input.file === "string") files.push(input.file);
+    if (Array.isArray(input.files)) {
+      for (const item of input.files) {
+        if (typeof item === "string") files.push(item);
+      }
+    }
+
+    return files;
+  }
+
+  function buildDiagnosticsOutput(
+    filePath: string,
+    diagnostics: Diagnostic[],
+    cwd: string,
+    includeFileHeader: boolean,
+  ): { notification: string; errorCount: number; output: string } {
+    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
+    const relativePath = path.relative(cwd, absPath);
+    const errorCount = diagnostics.filter((e) => e.severity === 1).length;
+
+    const MAX = 5;
+    const lines = diagnostics.slice(0, MAX).map((e) => {
+      const sev = e.severity === 1 ? "ERROR" : "WARN";
+      return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`;
+    });
+
+    let notification = `๐Ÿ“‹ ${relativePath}\n${lines.join("\n")}`;
+    if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`;
+
+    const header = includeFileHeader ? `File: ${relativePath}\n` : "";
+    const output = `\n${header}This file has errors, please fix\n<file_diagnostics>\n${diagnostics.map(formatDiagnostic).join("\n")}\n</file_diagnostics>\n`;
+
+    return { notification, errorCount, output };
+  }
+
+  async function collectDiagnostics(
+    filePath: string,
+    ctx: ExtensionContext,
+    includeWarnings: boolean,
+    includeFileHeader: boolean,
+    notify = true,
+  ): Promise<string | undefined> {
+    const manager = getOrCreateManager(ctx.cwd);
+    const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
+    if (!absPath) return undefined;
+
+    try {
+      const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath));
+      if (!result.receivedResponse) return undefined;
+
+      const diagnostics = includeWarnings
+        ? result.diagnostics
+        : result.diagnostics.filter((d) => d.severity === 1);
+      if (!diagnostics.length) return undefined;
+
+      const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader);
+
+      if (notify) {
+        if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning");
+        else console.error(report.notification);
+      }
+
+      return report.output;
+    } catch {
+      return undefined;
+    }
+  }
+
+  pi.registerCommand("lsp", {
+    description: "LSP settings (auto diagnostics hook)",
+    handler: async (_args, ctx) => {
+      if (!ctx.hasUI) {
+        ctx.ui.notify("LSP settings require UI", "warning");
+        return;
+      }
+
+      const currentMark = " โœ“";
+      const modeOptions = ([
+        "edit_write",
+        "agent_end",
+        "disabled",
+      ] as HookMode[]).map((mode) => ({
+        mode,
+        label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode),
+      }));
+
+      const modeChoice = await ctx.ui.select(
+        "LSP auto diagnostics hook mode:",
+        modeOptions.map((option) => option.label),
+      );
+      if (!modeChoice) return;
+
+      const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode;
+      if (!nextMode) return;
+
+      const scopeOptions = [
+        {
+          scope: "session" as HookScope,
+          label: "Session only",
+        },
+        {
+          scope: "global" as HookScope,
+          label: "Global (all sessions)",
+        },
+      ];
+
+      const scopeChoice = await ctx.ui.select(
+        "Apply LSP auto diagnostics hook setting to:",
+        scopeOptions.map((option) => option.label),
+      );
+      if (!scopeChoice) return;
+
+      const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope;
+      if (!scope) return;
+      if (scope === "global") {
+        const ok = setGlobalHookMode(nextMode);
+        if (!ok) {
+          ctx.ui.notify("Failed to update global settings", "error");
+          return;
+        }
+      }
+
+      hookMode = nextMode;
+      hookScope = scope;
+      touchedFiles.clear();
+      persistHookEntry({ scope, hookMode: nextMode });
+      updateLspStatus();
+      ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info");
+    },
+  });
+
+  pi.on("session_start", async (_event, ctx) => {
+    restoreHookState(ctx);
+    statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null;
+    updateLspStatus();
+
+    if (hookMode === "disabled") return;
+
+    const manager = getOrCreateManager(ctx.cwd);
+
+    for (const [marker, ext] of Object.entries(WARMUP_MAP)) {
+      if (fs.existsSync(path.join(ctx.cwd, marker))) {
+        setActivity("loading");
+        manager.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`))
+          .then((clients) => {
+            if (clients.length > 0) {
+              const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext));
+              if (cfg) activeClients.add(cfg.id);
+            }
+          })
+          .catch(() => {})
+          .finally(() => setActivity("idle"));
+        break;
+      }
+    }
+  });
+
+  pi.on("session_switch", async (_event, ctx) => {
+    restoreHookState(ctx);
+    updateLspStatus();
+  });
+
+  pi.on("session_tree", async (_event, ctx) => {
+    restoreHookState(ctx);
+    updateLspStatus();
+  });
+
+  pi.on("session_fork", async (_event, ctx) => {
+    restoreHookState(ctx);
+    updateLspStatus();
+  });
+
+  pi.on("session_shutdown", async () => {
+    shuttingDown = true;
+    clearIdleShutdownTimer();
+    diagnosticsAbort?.abort();
+    diagnosticsAbort = null;
+    setActivity("idle");
+
+    await shutdownManager();
+    activeClients.clear();
+    statusUpdateFn?.("lsp", undefined);
+  });
+
+  pi.on("tool_call", async (event, ctx) => {
+    const input = (event.input && typeof event.input === "object")
+      ? event.input as Record<string, unknown>
+      : {};
+
+    if (event.toolName === "lsp") {
+      clearIdleShutdownTimer();
+      const files = extractLspFiles(input);
+      for (const file of files) {
+        ensureActiveClientForFile(file, ctx.cwd);
+      }
+      return;
+    }
+
+    if (event.toolName !== "read" && event.toolName !== "write" && event.toolName !== "edit") return;
+
+    clearIdleShutdownTimer();
+    const filePath = typeof input.path === "string" ? input.path : undefined;
+    if (!filePath) return;
+
+    const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
+    if (!absPath) return;
+
+    void getOrCreateManager(ctx.cwd).getClientsForFile(absPath).catch(() => {});
+  });
+
+  pi.on("agent_start", async () => {
+    clearIdleShutdownTimer();
+    diagnosticsAbort?.abort();
+    diagnosticsAbort = null;
+    setActivity("idle");
+    touchedFiles.clear();
+  });
+
+  function agentWasAborted(event: any): boolean {
+    const messages = Array.isArray(event?.messages) ? event.messages : [];
+    return messages.some((m: any) =>
+      m &&
+      typeof m === "object" &&
+      (m as any).role === "assistant" &&
+      (((m as any).stopReason === "aborted") || ((m as any).stopReason === "error"))
+    );
+  }
+
+  pi.on("agent_end", async (event, ctx) => {
+    try {
+      if (hookMode !== "agent_end") return;
+
+      if (agentWasAborted(event)) {
+        // Don't run diagnostics on aborted/error runs.
+        touchedFiles.clear();
+        return;
+      }
+
+      if (touchedFiles.size === 0) return;
+      if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
+
+      const abort = new AbortController();
+      diagnosticsAbort?.abort();
+      diagnosticsAbort = abort;
+
+      setActivity("working");
+
+      const files = Array.from(touchedFiles.entries());
+      touchedFiles.clear();
+
+      try {
+        const outputs: string[] = [];
+        for (const [filePath, includeWarnings] of files) {
+          if (shuttingDown || abort.signal.aborted) return;
+          if (!ctx.isIdle() || ctx.hasPendingMessages()) {
+            abort.abort();
+            return;
+          }
+
+          const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false);
+          if (abort.signal.aborted) return;
+          if (output) outputs.push(output);
+        }
+
+        if (shuttingDown || abort.signal.aborted) return;
+
+        if (outputs.length) {
+          pi.sendMessage({
+            customType: "lsp-diagnostics",
+            content: outputs.join("\n"),
+            display: true,
+          }, {
+            triggerTurn: true,
+            deliverAs: "followUp",
+          });
+        }
+      } finally {
+        if (diagnosticsAbort === abort) diagnosticsAbort = null;
+        if (!shuttingDown) setActivity("idle");
+      }
+    } finally {
+      if (!shuttingDown) scheduleIdleShutdown();
+    }
+  });
+
+  pi.on("tool_result", async (event, ctx) => {
+    if (event.toolName !== "write" && event.toolName !== "edit") return;
+
+    const filePath = event.input.path as string;
+    if (!filePath) return;
+
+    const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
+    if (!absPath) return;
+
+    if (hookMode === "disabled") return;
+
+    if (hookMode === "agent_end") {
+      const includeWarnings = event.toolName === "write";
+      const existing = touchedFiles.get(absPath) ?? false;
+      touchedFiles.set(absPath, existing || includeWarnings);
+      return;
+    }
+
+    const includeWarnings = event.toolName === "write";
+    const output = await collectDiagnostics(absPath, ctx, includeWarnings, false);
+    if (!output) return;
+
+    return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> };
+  });
+}
dots/pi/agent/extensions/lsp/package.json
@@ -0,0 +1,54 @@
+{
+  "name": "lsp-pi",
+  "version": "1.0.3",
+  "description": "LSP extension for pi-coding-agent - provides language server tool and diagnostics feedback for Dart/Flutter, TypeScript, Vue, Svelte, Python, Go, Kotlin, Swift, Rust",
+  "scripts": {
+    "test": "npx tsx tests/lsp.test.ts",
+    "test:tool": "npx tsx tests/index.test.ts",
+    "test:integration": "npx tsx tests/lsp-integration.test.ts",
+    "test:all": "npm test && npm run test:tool && npm run test:integration"
+  },
+  "keywords": [
+    "lsp",
+    "language-server",
+    "dart",
+    "flutter",
+    "typescript",
+    "vue",
+    "svelte",
+    "python",
+    "go",
+    "kotlin",
+    "swift",
+    "rust",
+    "pi-coding-agent",
+    "extension",
+    "pi-package"
+  ],
+  "author": "",
+  "license": "MIT",
+  "type": "module",
+  "pi": {
+    "extensions": [
+      "./lsp.ts",
+      "./lsp-tool.ts"
+    ]
+  },
+  "dependencies": {
+    "@sinclair/typebox": "^0.34.33",
+    "vscode-languageserver-protocol": "^3.17.5"
+  },
+  "peerDependencies": {
+    "@mariozechner/pi-ai": "^0.50.0",
+    "@mariozechner/pi-coding-agent": "^0.50.0",
+    "@mariozechner/pi-tui": "^0.50.0"
+  },
+  "devDependencies": {
+    "@mariozechner/pi-ai": "^0.50.0",
+    "@mariozechner/pi-coding-agent": "^0.50.0",
+    "@mariozechner/pi-tui": "^0.50.0",
+    "@types/node": "^24.10.2",
+    "tsx": "^4.21.0",
+    "typescript": "^5.9.3"
+  }
+}
\ No newline at end of file