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:
20
README.md
20
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` | `[<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
|
||||
|
||||
```bash
|
||||
|
||||
188
index.ts
188
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<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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +191,24 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
};
|
||||
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.
|
||||
|
||||
@@ -43,3 +43,11 @@ export async function daemonStatus(): Promise<unknown> {
|
||||
export async function daemonShutdown(): Promise<unknown> {
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user