diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
| commit | 062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch) | |
| tree | 6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/api | |
| parent | b3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff) | |
| download | dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.tar.gz dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.zip | |
feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp)
Add Language Server Protocol integration modeled on opencode's, wired for
this codebase's plain-TypeScript tool/agent architecture.
Core (@dispatch/core):
- lsp/client.ts: LSP/JSON-RPC client over stdio (vscode-jsonrpc) with the
initialize handshake, didOpen/didChange sync, push + pull diagnostics
(textDocument/diagnostic, workspace/diagnostic), and a generic request()
passthrough for hover/definition/references/documentSymbol.
- lsp/server.ts: resolves dispatch.toml [lsp] entries into spawn specs.
Config-driven only — no builtin registry, no auto-download.
- lsp/manager.ts: process-wide LspManager owning client lifecycles, keyed
by root+serverID, lazy spawn + reuse + graceful shutdown.
- lsp/language.ts: extension->languageId map incl. .luau -> "luau".
- lsp/diagnostic.ts: error-only <diagnostics> block formatting (1-based).
- tools/lsp.ts: on-demand 'lsp' tool (1-based coords -> 0-based wire).
- write-file.ts: optional onAfterWrite hook for diagnostics-on-write.
- config schema: validate [lsp] block; DispatchConfig.lsp + LspServerConfig.
API (@dispatch/api):
- AgentManager owns one LspManager; per-working-directory server cache
cleared on config reload; diagnostics appended to write_file results;
'lsp' tool gated by new perm_lsp setting; shutdownAll on destroy().
Config:
- dispatch.toml: documented, commented [lsp.luau-lsp] Roblox example.
Tests: fake-lsp-server fixture + client/manager/server/diagnostic/schema/
tool/write-hook suites, plus an opt-in real-binary luau-lsp smoke test
(auto-skipped when luau-lsp is absent). 652 pass; biome + 3 typechecks green.
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 150 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 30 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 30 |
3 files changed, 207 insertions, 3 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 2795a6c..7af2b67 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -14,6 +14,7 @@ import { configToRuleset, createConfigWatcher, createListFilesTool, + createLspTool, createReadFileSliceTool, createReadFileTool, createReadTabTool, @@ -37,6 +38,7 @@ import { getSetting, getTab, getUsageStatsForTab, + LspManager, listOpenTabs, loadAgent, loadAgents, @@ -45,9 +47,12 @@ import { ModelRegistry, type QueuedMessage, type ReasoningEffort, + type ResolvedLspServer, refreshAccountCredentials, refreshAccountCredentialsAsync, + reportDiagnostics, resolveApiKey, + resolveServersFromConfig, resolveTabPrefix, type SkillDefinition, type SystemChunkKind, @@ -87,6 +92,7 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.", read_tab: "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.", + lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.", }; /** @@ -99,6 +105,14 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { */ const MAX_AGENT_AUTO_WAKES = 6; +/** + * Cap on how many OTHER files' LSP error blocks are appended to a write_file + * result, after the written file's own errors. Bounds context spend when a + * single edit surfaces project-wide diagnostics. Mirrors opencode's + * MAX_PROJECT_DIAGNOSTICS_FILES. + */ +const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5; + const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; @@ -251,6 +265,21 @@ export class AgentManager { private claudeAccounts: ClaudeAccount[] = []; + /** + * Process-wide owner of LSP client lifecycles. Servers are declared in the + * `dispatch.toml` of a tab's effective working directory; clients are + * spawned lazily per (root + server) and reused across tabs/turns. Shut + * down in `destroy()`. + */ + private lspManager: LspManager = new LspManager(); + /** + * Cache of resolved LSP servers per working directory, so we parse each + * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any + * config hot-reload (the watcher fires for the root config; directory-level + * configs are re-read on demand after a clear). + */ + private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map(); + constructor(permissionManager?: PermissionManager) { this.permissionManager = permissionManager; @@ -292,6 +321,10 @@ export class AgentManager { } // Update model registry with new config this._initModelRegistry(newConfig); + // LSP server config may have changed — drop the per-directory cache + // so the next tool build re-reads each working directory's + // `dispatch.toml` `[lsp]` block. + this.lspServersByDir.clear(); // Re-discover Claude accounts: a config reload may accompany freshly // imported credentials, and (critically) lets a process that failed // account discovery at boot recover without a full restart. @@ -334,6 +367,77 @@ export class AgentManager { } } + /** + * Resolve (and cache) the LSP servers configured for a working directory. + * + * LSP config is project-scoped: it lives in the `dispatch.toml` of the + * tab's effective working directory, NOT the global config. We read that + * directory's config once and cache the resolved servers; the cache is + * cleared on config hot-reload. Returns `[]` when the directory has no + * `[lsp]` block (the common case). + */ + private getLspServersForDir(dir: string): ResolvedLspServer[] { + const cached = this.lspServersByDir.get(dir); + if (cached) return cached; + let servers: ResolvedLspServer[] = []; + try { + const dirConfig = loadConfig(dir); + servers = resolveServersFromConfig(dirConfig.lsp); + } catch (err) { + console.warn( + `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`, + ); + servers = []; + } + this.lspServersByDir.set(dir, servers); + return servers; + } + + /** + * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's + * working directory has LSP servers configured. The hook touches the + * just-written file through the LSP and returns a formatted diagnostics + * block (the written file's errors first, then a small cap of other-file + * errors) — opencode's diagnostics-on-write pattern. Returns `undefined` + * when no server matches, so writes stay zero-overhead for non-LSP files. + */ + private buildAfterWriteHook( + workingDirectory: string, + servers: ResolvedLspServer[], + ): ((absolutePath: string) => Promise<string>) | undefined { + if (servers.length === 0) return undefined; + const manager = this.lspManager; + return async (absolutePath: string): Promise<string> => { + if (!manager.hasServerForFile(absolutePath, servers)) return ""; + await manager.touchFile({ + file: absolutePath, + root: workingDirectory, + servers, + mode: "document", + }); + const diagnostics = manager.getDiagnostics({ + root: workingDirectory, + servers, + file: absolutePath, + }); + let output = ""; + let otherFileCount = 0; + for (const [file, issues] of Object.entries(diagnostics)) { + const current = file === absolutePath; + if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue; + const block = reportDiagnostics(file, issues); + if (!block) continue; + if (current) { + output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`; + } else { + otherFileCount++; + output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`; + } + } + return output; + }; + } + private _initModelRegistry(config: DispatchConfig): void { if (config.keys) { if (this.modelRegistry) { @@ -408,8 +512,9 @@ export class AgentManager { const permReadTab = getSetting("perm_read_tab") === "allow"; const permWebSearch = getSetting("perm_web_search") === "allow"; const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; + const permLsp = getSetting("perm_lsp") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permLsp}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -451,6 +556,12 @@ export class AgentManager { // Ignore — tool execution will surface the error naturally } + // Resolve LSP servers for this working directory once (cached). + // Drives both diagnostics-on-write (the write_file hook) and the + // optional `lsp` tool. Empty for directories with no `[lsp]` block. + const lspServers = this.getLspServersForDir(workingDirectory); + const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers); + // Build tools list — child agents use their toolsOverride whitelist, // parent agents use permission settings from DB const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = []; @@ -475,7 +586,10 @@ export class AgentManager { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (allowed.has("write_file")) { - toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + toolEntries.push({ + name: "write_file", + tool: createWriteFileTool(workingDirectory, afterWriteHook), + }); } if (allowed.has("run_shell")) { toolEntries.push({ @@ -486,6 +600,16 @@ export class AgentManager { if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + if (allowed.has("lsp") && lspServers.length > 0) { + toolEntries.push({ + name: "lsp", + tool: createLspTool(() => ({ + manager: this.lspManager, + workingDirectory, + servers: lspServers, + })), + }); + } if (allowed.has("youtube_transcribe")) { toolEntries.push({ name: "youtube_transcribe", @@ -561,7 +685,10 @@ export class AgentManager { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (permEdit) { - toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + toolEntries.push({ + name: "write_file", + tool: createWriteFileTool(workingDirectory, afterWriteHook), + }); } if (permBash) { toolEntries.push({ @@ -572,6 +699,19 @@ export class AgentManager { if (permWebSearch) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + // The `lsp` tool exposes diagnostics + navigation on demand. It is + // gated by `perm_lsp` AND requires at least one server configured + // in the working directory's `dispatch.toml`. + if (permLsp && lspServers.length > 0) { + toolEntries.push({ + name: "lsp", + tool: createLspTool(() => ({ + manager: this.lspManager, + workingDirectory, + servers: lspServers, + })), + }); + } if (permYoutubeTranscribe) { toolEntries.push({ name: "youtube_transcribe", @@ -1858,5 +1998,9 @@ export class AgentManager { destroy(): void { this.configWatcher?.close(); this.skillsWatcher?.close(); + // Shut down all long-lived LSP server processes. Fire-and-forget: the + // promise is detached so `destroy()` stays synchronous (matching its + // existing contract), but every client gets `shutdown()` called. + void this.lspManager.shutdownAll(); } } diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 3353aff..6efe15e 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -228,6 +228,36 @@ vi.mock("@dispatch/core", () => ({ execute: async () => ["file1.ts"], }; }, + createLspTool(_getContext: unknown): ToolDefinition { + return { + name: "lsp", + description: "query the language server", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock lsp", + }; + }, + LspManager: class MockLspManager { + hasServerForFile() { + return false; + } + async getClients() { + return []; + } + async touchFile() {} + getDiagnostics() { + return {}; + } + async request() { + return []; + } + async shutdownAll() {} + }, + resolveServersFromConfig(_lsp: unknown) { + return []; + }, + reportDiagnostics(_file: string, _issues: unknown) { + return ""; + }, createRunShellTool(_wd: string): ToolDefinition { return { name: "run_shell", diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index a8db5ce..6ec8ca6 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -82,6 +82,36 @@ vi.mock("@dispatch/core", () => ({ execute: async () => ["file1.ts"], }; }, + createLspTool(_getContext: unknown): ToolDefinition { + return { + name: "lsp", + description: "query the language server", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock lsp", + }; + }, + LspManager: class MockLspManager { + hasServerForFile() { + return false; + } + async getClients() { + return []; + } + async touchFile() {} + getDiagnostics() { + return {}; + } + async request() { + return []; + } + async shutdownAll() {} + }, + resolveServersFromConfig(_lsp: unknown) { + return []; + }, + reportDiagnostics(_file: string, _issues: unknown) { + return ""; + }, createRunShellTool(_wd: string): ToolDefinition { return { name: "run_shell", |
