Commit dde51304baf7
Changed files (6)
dots
pi
agent
extensions
systems
aomi
dots/pi/agent/extensions/lsp/.gitignore
@@ -1,1 +0,0 @@
-node_modules
dots/pi/agent/extensions/lsp/lsp-core.ts
@@ -1,1142 +0,0 @@
-/**
- * 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
@@ -1,386 +0,0 @@
-/**
- * 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",
- promptGuidelines: [
- "Use lsp for go-to-definition, find-references, hover, and rename โ faster and more accurate than grep for supported languages",
- "Use lsp diagnostics to check for errors before suggesting fixes",
- ],
- 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
@@ -1,604 +0,0 @@
-/**
- * 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
@@ -1,54 +0,0 @@
-{
- "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
systems/aomi/home.nix
@@ -23,7 +23,6 @@
home.sessionVariables = {
PASSAGE_IDENTITIES_FILE = pkgs.lib.mkForce "${config.home.homeDirectory}/.ssh/id_ed25519";
-
};
home.packages = with pkgs; [