feat: add server control commands (disable, enable, destroy)

Add /lsp-servers, /lsp-disable, /lsp-enable, and /lsp-destroy TUI commands.
Disabled servers are tracked in-memory per-extension-instance; the shared
daemon is never mutated by disable/enable. When all servers are disabled,
LSP tools are removed from the active tool set so the LLM won't attempt them.

Also adds a destroy_server daemon operation that kills running LspClient
entries by server ID or all entries.
This commit is contained in:
2026-04-30 09:48:01 -04:00
parent 7abe4efa02
commit e131e0e8cd
5 changed files with 237 additions and 3 deletions

188
index.ts
View File

@@ -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<string>();
// 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<string, unknown>,
): Promise<unknown> {
// 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");
}
},
});
}