diff --git a/README.md b/README.md index a98c9e6..9ac2a0d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,26 @@ Run diagnostics manually on specific files: /lsp-check main.go utils.go ``` +### Server Control Commands + +Disable a server so this pi instance won't use it (the shared daemon and other instances are unaffected). When all servers are disabled, LSP tools are removed from the active tool set. + +| Command | Args | Behavior | +|---------|------|----------| +| `/lsp-servers` | none | List running servers and disabled state | +| `/lsp-disable` | `[]` | Disable all (no arg) or specific server. Bare command disables all. | +| `/lsp-enable` | `[]` | Enable all (no arg) or specific server. Restores tools when any is enabled. | +| `/lsp-destroy` | `[]` | Kill running daemon entries for all (no arg) or specific server. Explicitly destructive. | + +```bash +/lsp-disable gopls # Disable just gopls; other LSP tools still work +/lsp-disable # Disable all — removes LSP tools from active set +/lsp-enable gopls # Re-enable gopls; restores tools +/lsp-enable # Re-enable all +/lsp-destroy gopls # Kill running gopls process(es) in the daemon +/lsp-destroy # Kill all running server processes +``` + ## Install ```bash diff --git a/index.ts b/index.ts index c74a6d7..89fd4af 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,14 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import * as path from "node:path"; import { uriToPath } from "./src/client.ts"; -import { daemonDiagnostics, daemonRequest } from "./src/daemonClient.ts"; +import { + daemonDestroyServer, + daemonDiagnostics, + daemonRequest, + daemonStatus, +} from "./src/daemonClient.ts"; +import { pickServer } from "./src/root.ts"; +import { servers } from "./server.ts"; import { ServerNotFoundError, UnsupportedExtensionError, @@ -216,14 +223,35 @@ function isExpectedError(error: unknown): boolean { ); } +// Disabled Servers - In-memory set of disabled server IDs. Scoped to this +// extension process; the shared daemon is never mutated by disable/enable. +const lspToolNames = [ + "lsp_hover", + "lsp_definition", + "lsp_references", + "lsp_completion", + "lsp_documentSymbol", + "lsp_diagnostics", +]; +const disabledServers = new Set(); + // Run LSP Request - Forwards to the daemon, which owns the long-lived // LspClient cache and handles didOpen/didChange syncing. The daemon // injects textDocument.uri from the file path, so we omit it here. +// Gated by disabledServers — throws early if the target server is disabled. async function runLsp( filePath: string, method: string, params: Record, ): Promise { + // Check Disabled - The server for this file is blocked; bail before + // touching the daemon so other pi instances sharing it are unaffected. + const server = pickServer(filePath); + if (disabledServers.has(server.id)) { + throw new Error( + `LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`, + ); + } try { return await daemonRequest(filePath, method, params); } catch (error) { @@ -409,6 +437,9 @@ export default function (pi: ExtensionAPI) { // Check Enabled if (!pi.getFlag("lsp-auto-check")) return; + // Skip If All Disabled - No LSP server is available for this instance. + if (servers.every((s) => disabledServers.has(s.id))) return; + // Edit & Write Only if (!["edit", "write"].includes(event.toolName)) return; @@ -482,4 +513,159 @@ export default function (pi: ExtensionAPI) { } }, }); + + // --- Server Control Commands --- + + // Shared Argument Completions - Suggests registered server IDs plus "all". + const serverCompletions = (prefix: string) => { + const ids = [...servers.map((s) => s.id), "all"].filter((id) => + id.startsWith(prefix), + ); + return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null; + }; + + // Parse Server IDs - Validates args against registered servers. Bare/empty + // or "all" returns every server ID. Throws on unknown names. + function parseServerIds(args: string | undefined): string[] { + if (!args || !args.trim()) return servers.map((s) => s.id); + const ids = args.trim().split(/\s+/); + if (ids.includes("all")) return servers.map((s) => s.id); + const invalid = ids.filter((id) => !servers.some((s) => s.id === id)); + if (invalid.length > 0) { + throw new Error( + `Unknown server(s): ${invalid.join( + ", ", + )}. Available: ${servers.map((s) => s.id).join(", ")}`, + ); + } + return ids; + } + + // Update Tool Visibility - When all servers are disabled, remove LSP tools + // from the active set so the LLM won't attempt them. When any is enabled, + // restore them. Captures current active tools at toggle time. + function updateToolVisibility(): void { + const current = pi.getActiveTools().map((t) => t.name); + if (servers.every((s) => disabledServers.has(s.id))) { + // All disabled — strip LSP tools + pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name))); + } else { + // Any enabled — merge LSP tools back in + const merged = [...new Set([...current, ...lspToolNames])]; + pi.setActiveTools(merged); + } + } + + // List Servers - Show running daemon entries and disabled state. + pi.registerCommand("lsp-servers", { + description: "List running LSP servers and disabled state", + handler: async (_args, ctx) => { + try { + const status = (await daemonStatus()) as { servers?: unknown[] }; + const running = Array.isArray(status?.servers) ? status.servers : []; + const disabled = Array.from(disabledServers); + + if (running.length === 0 && disabled.length === 0) { + ctx.ui.notify("No running servers, none disabled", "info"); + return; + } + + let msg = ""; + if (running.length > 0) { + msg += `Running: ${running.map((s: any) => s.id).join(", ")}\n`; + } + if (disabled.length > 0) { + msg += `Disabled: ${disabled.join(", ")}`; + } + ctx.ui.notify(msg.trim(), "info"); + } catch (error) { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : "Unknown error"; + ctx.ui.notify(`Status failed: ${msg}`, "error"); + } + }, + }); + + // Disable Servers - Add to disabled set; removes LSP tools when all are + // disabled so the LLM won't waste context on them. + pi.registerCommand("lsp-disable", { + description: + "Disable LSP server(s) — bare command disables all. Removes tools when all are disabled.", + getArgumentCompletions: serverCompletions, + handler: async (args, ctx) => { + try { + const ids = parseServerIds(args); + for (const id of ids) { + disabledServers.add(id); + } + updateToolVisibility(); + const label = + ids.length === servers.length ? "all servers" : ids.join(", "); + ctx.ui.notify(`Disabled: ${label}`, "info"); + } catch (error) { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : "Unknown error"; + ctx.ui.notify(msg, "error"); + } + }, + }); + + // Enable Servers - Remove from disabled set; restores LSP tools when any + // server becomes available. + pi.registerCommand("lsp-enable", { + description: + "Enable LSP server(s) — bare command enables all. Restores tools when any is enabled.", + getArgumentCompletions: serverCompletions, + handler: async (args, ctx) => { + try { + const ids = parseServerIds(args); + for (const id of ids) { + disabledServers.delete(id); + } + updateToolVisibility(); + const label = + ids.length === servers.length ? "all servers" : ids.join(", "); + ctx.ui.notify(`Enabled: ${label}`, "info"); + } catch (error) { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : "Unknown error"; + ctx.ui.notify(msg, "error"); + } + }, + }); + + // Destroy Servers - Kill running LspClient entries in the daemon. Entries + // can respawn on next request; pair with /lsp-disable to also block. + pi.registerCommand("lsp-destroy", { + description: + "Kill running LSP server process(es) in the daemon — bare command destroys all.", + getArgumentCompletions: serverCompletions, + handler: async (args, ctx) => { + try { + const ids = parseServerIds(args); + if (ids.length === servers.length) { + await daemonDestroyServer(); + } else { + for (const id of ids) { + await daemonDestroyServer(id); + } + } + const label = + ids.length === servers.length ? "all servers" : ids.join(", "); + ctx.ui.notify(`Destroyed: ${label}`, "info"); + } catch (error) { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : "Unknown error"; + ctx.ui.notify(msg, "error"); + } + }, + }); } diff --git a/src/daemon.ts b/src/daemon.ts index 3e77c82..f75e79a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -191,6 +191,24 @@ async function handle(req: DaemonRequest): Promise { }; return { id: req.id, ok: true, result }; } + case "destroy_server": { + // Manual Kill - Evict entries matching the server ID (or all if + // unspecified). This is explicitly destructive; the caller knows + // what it's doing. + const toDestroy = req.serverId + ? Array.from(entries.values()).filter( + (e) => e.server.id === req.serverId, + ) + : Array.from(entries.values()); + for (const entry of toDestroy) { + evict(entry, "manual destroy"); + } + return { + id: req.id, + ok: true, + result: { destroyed: toDestroy.map((e) => e.key) }, + }; + } case "shutdown": { // Acknowledge first, then tear down on next tick so the response // has a chance to flush before we close listeners. diff --git a/src/daemonClient.ts b/src/daemonClient.ts index 6f9d0cb..12038e2 100644 --- a/src/daemonClient.ts +++ b/src/daemonClient.ts @@ -43,3 +43,11 @@ export async function daemonStatus(): Promise { export async function daemonShutdown(): Promise { return unwrap(await sendOnce({ op: "shutdown" })); } + +// Destroy Server - Kills running LspClient entries matching a server ID, +// or all entries if no ID is given. Entries can respawn on next request. +export async function daemonDestroyServer( + serverId?: string, +): Promise { + return unwrap(await sendOnce({ op: "destroy_server", serverId })); +} diff --git a/src/daemonProtocol.ts b/src/daemonProtocol.ts index 70749fa..64a4206 100644 --- a/src/daemonProtocol.ts +++ b/src/daemonProtocol.ts @@ -21,7 +21,8 @@ export type DaemonRequest = } | { id: number; op: "diagnostics"; file: string; timeoutMs?: number } | { id: number; op: "status" } - | { id: number; op: "shutdown" }; + | { id: number; op: "shutdown" } + | { id: number; op: "destroy_server"; serverId?: string }; export type DaemonRequestWithoutId = | { @@ -32,7 +33,8 @@ export type DaemonRequestWithoutId = } | { op: "diagnostics"; file: string; timeoutMs?: number } | { op: "status" } - | { op: "shutdown" }; + | { op: "shutdown" } + | { op: "destroy_server"; serverId?: string }; // Response Shapes - Sent daemon -> client. `ok: true` carries result, else // `error` is a human-readable message string.