Commit cbc867e44a06
Changed files (5)
dots
pi
agent
extensions
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