/** * Pi LSP Extension * * Language-agnostic code intelligence via LSP. * Auto-detects servers by file extension, configurable via: * - ~/.pi/agent/extensions/lsp/config.json (global defaults) * - .pi/lsp.json (project overrides) * * Any LSP server can be added via config. */ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { Effect } from 'effect'; import { LspClient } from './client'; import { loadConfigEffect, scaffoldGlobalConfigEffect, serversForExtension, type LoadedConfig, } from './config'; import { makeRuntimeLayer, type LspServices } from './effects/runtime'; import { errorMessage } from './errors'; import { registerLspTool, type ServerManagerService } from './tools'; import type { ResolvedServerConfig } from './types'; export default function lspExtension(pi: ExtensionAPI) { let rootPath = ''; let config: LoadedConfig | null = null; const clients = new Map(); const runtimeLayer = makeRuntimeLayer(); function runLsp(program: Effect.Effect): Promise { return Effect.runPromise(program.pipe(Effect.provide(runtimeLayer))); } // ── Client management ─────────────────────────────────────────────── function getOrCreateClient(serverConfig: ResolvedServerConfig): LspClient { const existing = clients.get(serverConfig.name); if (existing) return existing; const client = new LspClient(serverConfig, rootPath); clients.set(serverConfig.name, client); return client; } async function shutdownAll(): Promise { const shutdowns = [...clients.values()].map((c) => c.shutdown().catch(() => {})); await Promise.all(shutdowns); clients.clear(); } function refreshStatus( ui: { setStatus: (key: string, value: string) => void }, cfg: LoadedConfig | null, ) { if (!cfg) { ui.setStatus('lsp', 'LSP: no servers detected'); return; } if (cfg.globalDisabled) { ui.setStatus('lsp', 'LSP: disabled'); return; } if (cfg.servers.length === 0) { ui.setStatus('lsp', 'LSP: no servers detected'); return; } const running = cfg.servers.filter((server) => clients.get(server.name)?.isInitialized); if (running.length > 0) { ui.setStatus('lsp', `LSP: ${running.map((s) => s.name).join(', ')} (running)`); return; } ui.setStatus('lsp', `LSP: ${cfg.servers.map((s) => s.name).join(', ')}`); } // ── Server manager (passed to tool) ─────────────────────────────────── const serverManager: ServerManagerService = { clientsForFile(filePath: string): LspClient[] { if (!config) return []; const matching = serversForExtension(config.servers, filePath); return matching.map((s) => getOrCreateClient(s)); }, clientForFileWithCapability(filePath: string, capability: string): LspClient | null { if (!config) return null; const matching = serversForExtension(config.servers, filePath); for (const serverConfig of matching) { const client = getOrCreateClient(serverConfig); // If not yet initialized, return it (capability check happens after init) if (!client.isInitialized) return client; if (client.hasCapability(capability)) return client; } return null; }, anyClient(): LspClient | null { // Return first initialized client, or first available for (const client of clients.values()) { if (client.isInitialized) return client; } // Try to create one from config if (config && config.servers.length > 0) { return getOrCreateClient(config.servers[0]); } return null; }, getRootPath: () => rootPath, }; // ── Register tool ───────────────────────────────────────────────────── registerLspTool(pi, serverManager); // ── Session lifecycle ───────────────────────────────────────────────── pi.on('session_start', async (_event, ctx) => { rootPath = ctx.cwd; const scaffolded = await runLsp(scaffoldGlobalConfigEffect(rootPath)).catch((err) => { ctx.ui.notify(`LSP: could not scaffold config: ${errorMessage(err)}`, 'warning'); return false; }); if (scaffolded) { ctx.ui.notify( 'LSP: created starter config at ~/.pi/agent/extensions/lsp/config.json — every server is disabled, enable the ones you need.', 'info', ); } config = await runLsp(loadConfigEffect(rootPath)); refreshStatus(ctx.ui, config); }); pi.on('session_shutdown', async () => { await shutdownAll(); config = null; }); pi.on('tool_execution_end', async (event, ctx) => { if (event.toolName !== 'lsp') return; refreshStatus(ctx.ui, config); }); // ── Commands ────────────────────────────────────────────────────────── pi.registerCommand('lsp', { description: 'Show LSP server status', handler: async (_args, ctx) => { rootPath = ctx.cwd; const cfg = await runLsp(loadConfigEffect(ctx.cwd)); config = cfg; refreshStatus(ctx.ui, cfg); const lines: string[] = ['LSP Status:']; if (cfg.globalDisabled) { lines.push(' All servers disabled via config.'); } else if (cfg.servers.length === 0) { lines.push(' No servers configured.'); lines.push(' Add servers to ~/.pi/agent/extensions/lsp/config.json or .pi/lsp.json'); } else { for (const server of cfg.servers) { const client = clients.get(server.name); const status = client?.isInitialized ? 'running' : 'available (lazy start)'; const exts = server.extensions.join(', '); lines.push(` ${server.name}: ${status} — handles ${exts}`); } } if (cfg.errors.length > 0) { lines.push('', 'Config errors:'); for (const err of cfg.errors) lines.push(` - ${err}`); } ctx.ui.notify(lines.join('\n'), 'info'); }, }); pi.registerCommand('lsp-restart', { description: 'Restart all LSP servers', handler: async (_args, ctx) => { await shutdownAll(); config = null; rootPath = ctx.cwd; config = await runLsp(loadConfigEffect(ctx.cwd)); refreshStatus(ctx.ui, config); ctx.ui.notify('LSP servers stopped. Will reinitialize on next tool use.', 'info'); }, }); }