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

View File

@@ -30,6 +30,26 @@ Run diagnostics manually on specific files:
/lsp-check main.go utils.go /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` | `[<id>]` | Disable all (no arg) or specific server. Bare command disables all. |
| `/lsp-enable` | `[<id>]` | Enable all (no arg) or specific server. Restores tools when any is enabled. |
| `/lsp-destroy` | `[<id>]` | 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 ## Install
```bash ```bash

188
index.ts
View File

@@ -6,7 +6,14 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox"; import { Type } from "typebox";
import * as path from "node:path"; import * as path from "node:path";
import { uriToPath } from "./src/client.ts"; 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 { import {
ServerNotFoundError, ServerNotFoundError,
UnsupportedExtensionError, 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 // Run LSP Request - Forwards to the daemon, which owns the long-lived
// LspClient cache and handles didOpen/didChange syncing. The daemon // LspClient cache and handles didOpen/didChange syncing. The daemon
// injects textDocument.uri from the file path, so we omit it here. // 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( async function runLsp(
filePath: string, filePath: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<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 { try {
return await daemonRequest(filePath, method, params); return await daemonRequest(filePath, method, params);
} catch (error) { } catch (error) {
@@ -409,6 +437,9 @@ export default function (pi: ExtensionAPI) {
// Check Enabled // Check Enabled
if (!pi.getFlag("lsp-auto-check")) return; 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 // Edit & Write Only
if (!["edit", "write"].includes(event.toolName)) return; 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");
}
},
});
} }

View File

@@ -191,6 +191,24 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
}; };
return { id: req.id, ok: true, result }; 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": { case "shutdown": {
// Acknowledge first, then tear down on next tick so the response // Acknowledge first, then tear down on next tick so the response
// has a chance to flush before we close listeners. // has a chance to flush before we close listeners.

View File

@@ -43,3 +43,11 @@ export async function daemonStatus(): Promise<unknown> {
export async function daemonShutdown(): Promise<unknown> { export async function daemonShutdown(): Promise<unknown> {
return unwrap(await sendOnce({ op: "shutdown" })); 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<unknown> {
return unwrap(await sendOnce({ op: "destroy_server", serverId }));
}

View File

@@ -21,7 +21,8 @@ export type DaemonRequest =
} }
| { id: number; op: "diagnostics"; file: string; timeoutMs?: number } | { id: number; op: "diagnostics"; file: string; timeoutMs?: number }
| { id: number; op: "status" } | { id: number; op: "status" }
| { id: number; op: "shutdown" }; | { id: number; op: "shutdown" }
| { id: number; op: "destroy_server"; serverId?: string };
export type DaemonRequestWithoutId = export type DaemonRequestWithoutId =
| { | {
@@ -32,7 +33,8 @@ export type DaemonRequestWithoutId =
} }
| { op: "diagnostics"; file: string; timeoutMs?: number } | { op: "diagnostics"; file: string; timeoutMs?: number }
| { op: "status" } | { op: "status" }
| { op: "shutdown" }; | { op: "shutdown" }
| { op: "destroy_server"; serverId?: string };
// Response Shapes - Sent daemon -> client. `ok: true` carries result, else // Response Shapes - Sent daemon -> client. `ok: true` carries result, else
// `error` is a human-readable message string. // `error` is a human-readable message string.