From 062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 17:52:14 +0900 Subject: feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- packages/api/src/agent-manager.ts | 150 ++++- packages/api/tests/agent-manager.test.ts | 30 + packages/api/tests/routes.test.ts | 30 + packages/core/package.json | 2 + packages/core/src/config/schema.ts | 107 +++- packages/core/src/index.ts | 15 +- packages/core/src/lsp/client.ts | 658 +++++++++++++++++++++ packages/core/src/lsp/diagnostic.ts | 41 ++ packages/core/src/lsp/index.ts | 18 + packages/core/src/lsp/language.ts | 72 +++ packages/core/src/lsp/manager.ts | 220 +++++++ packages/core/src/lsp/server.ts | 68 +++ packages/core/src/tools/lsp.ts | 135 +++++ packages/core/src/tools/write-file.ts | 30 +- packages/core/src/types/index.ts | 49 ++ packages/core/tests/config/lsp-schema.test.ts | 110 ++++ packages/core/tests/fixture/lsp/fake-lsp-server.js | 195 ++++++ packages/core/tests/lsp/client.test.ts | 146 +++++ packages/core/tests/lsp/diagnostic.test.ts | 67 +++ packages/core/tests/lsp/luau-lsp.smoke.test.ts | 63 ++ packages/core/tests/lsp/manager.test.ts | 120 ++++ packages/core/tests/lsp/server.test.ts | 41 ++ packages/core/tests/tools/lsp-tool.test.ts | 110 ++++ packages/core/tests/tools/write-file.test.ts | 46 ++ 24 files changed, 2516 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/lsp/client.ts create mode 100644 packages/core/src/lsp/diagnostic.ts create mode 100644 packages/core/src/lsp/index.ts create mode 100644 packages/core/src/lsp/language.ts create mode 100644 packages/core/src/lsp/manager.ts create mode 100644 packages/core/src/lsp/server.ts create mode 100644 packages/core/src/tools/lsp.ts create mode 100644 packages/core/tests/config/lsp-schema.test.ts create mode 100644 packages/core/tests/fixture/lsp/fake-lsp-server.js create mode 100644 packages/core/tests/lsp/client.test.ts create mode 100644 packages/core/tests/lsp/diagnostic.test.ts create mode 100644 packages/core/tests/lsp/luau-lsp.smoke.test.ts create mode 100644 packages/core/tests/lsp/manager.test.ts create mode 100644 packages/core/tests/lsp/server.test.ts create mode 100644 packages/core/tests/tools/lsp-tool.test.ts (limited to 'packages') 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 = { "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 = { */ 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 = 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) | undefined { + if (servers.length === 0) return undefined; + const manager = this.lspManager; + return async (absolutePath: string): Promise => { + 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 }> = []; @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 3ca568b..55dff0f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,8 @@ "chokidar": "^5.0.0", "smol-toml": "^1.6.1", "tree-sitter-bash": "^0.25.1", + "vscode-jsonrpc": "8.2.1", + "vscode-languageserver-types": "3.17.5", "web-tree-sitter": "^0.26.8", "zod": "^3.23.0", "zod-to-json-schema": "^3.25.2" diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a459a4d..304ee10 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,4 +1,9 @@ -import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js"; +import type { + ConfigError, + DispatchConfig, + KeyDefinition, + LspServerConfig, +} from "../types/index.js"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -100,6 +105,99 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi }; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function validateLspServer( + raw: unknown, + path: string, + errors: ConfigError[], +): LspServerConfig | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + + const disabled = raw.disabled === true; + + // `command` is required and must be a non-empty string array unless the + // entry is explicitly disabled (a disabled entry is skipped wholesale). + if (!disabled) { + if (!isStringArray(raw.command) || raw.command.length === 0) { + errors.push({ + path: `${path}.command`, + message: "must be a non-empty array of strings", + }); + return null; + } + // `extensions` is required for custom servers — without it the client + // cannot know which files should activate the server. + if (!isStringArray(raw.extensions) || raw.extensions.length === 0) { + errors.push({ + path: `${path}.extensions`, + message: 'must be a non-empty array of strings (e.g. [".luau"])', + }); + return null; + } + } else { + // Disabled entries still must not carry a malformed command/extensions + // if present, but we do not require them. + if (raw.command !== undefined && !isStringArray(raw.command)) { + errors.push({ path: `${path}.command`, message: "must be an array of strings" }); + return null; + } + if (raw.extensions !== undefined && !isStringArray(raw.extensions)) { + errors.push({ path: `${path}.extensions`, message: "must be an array of strings" }); + return null; + } + } + + if (raw.env !== undefined && !isStringRecord(raw.env)) { + errors.push({ + path: `${path}.env`, + message: "must be a flat string-keyed object", + }); + return null; + } + + if (raw.initialization !== undefined && !isRecord(raw.initialization)) { + errors.push({ + path: `${path}.initialization`, + message: "must be an object", + }); + return null; + } + + const server: LspServerConfig = { + command: (raw.command as string[] | undefined) ?? [], + extensions: (raw.extensions as string[] | undefined) ?? [], + ...(isStringRecord(raw.env) ? { env: raw.env } : {}), + ...(isRecord(raw.initialization) + ? { initialization: raw.initialization as Record } + : {}), + ...(disabled ? { disabled: true } : {}), + }; + return server; +} + +function validateLsp( + raw: unknown, + path: string, + errors: ConfigError[], +): Record | undefined { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return undefined; + } + const result: Record = {}; + for (const [id, value] of Object.entries(raw)) { + const server = validateLspServer(value, `${path}.${id}`, errors); + if (server) result[id] = server; + } + return Object.keys(result).length > 0 ? result : undefined; +} + export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { const errors: ConfigError[] = []; @@ -125,9 +223,16 @@ export function validateConfig(raw: unknown): { config: DispatchConfig; errors: } } + // lsp (optional) + let lsp: Record | undefined; + if (raw.lsp !== undefined) { + lsp = validateLsp(raw.lsp, "lsp", errors); + } + const config: DispatchConfig = { permissions, ...(keys !== undefined && { keys }), + ...(lsp !== undefined && { lsp }), }; return { config, errors }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8334102..8608b6a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,18 @@ export { logStreamEvent, } from "./llm/debug-logger.js"; export { createProvider } from "./llm/provider.js"; +// LSP (Language Server Protocol) +export { + createLspClient, + type Diagnostic as LspDiagnostic, + type LspClient, + LspManager, + type LspServerHandle, + pretty as prettyDiagnostic, + type ResolvedLspServer, + report as reportDiagnostics, + resolveServersFromConfig, +} from "./lsp/index.js"; // Models export { getModelsCatalog, @@ -88,6 +100,7 @@ export { export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; // Tools export { createListFilesTool } from "./tools/list-files.js"; +export { createLspTool, type LspToolContext } from "./tools/lsp.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createReadFileSliceTool } from "./tools/read-file-slice.js"; export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js"; @@ -111,7 +124,7 @@ export { export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js"; export { clearSpillForTab } from "./tools/truncate.js"; export { createWebSearchTool } from "./tools/web-search.js"; -export { createWriteFileTool } from "./tools/write-file.js"; +export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js"; export { BackgroundTranscriptStore, createYoutubeTranscribeTool, diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts new file mode 100644 index 0000000..da0c916 --- /dev/null +++ b/packages/core/src/lsp/client.ts @@ -0,0 +1,658 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + createMessageConnection, + type MessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { languageIdForExtension } from "./language.js"; + +export type { Diagnostic } from "vscode-languageserver-types"; + +// ─── Timing constants (mirrors opencode) ───────────────────────── +const DIAGNOSTICS_DEBOUNCE_MS = 150; +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000; +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000; +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000; +const INITIALIZE_TIMEOUT_MS = 45_000; + +// ─── LSP spec constants ────────────────────────────────────────── +const FILE_CHANGE_CREATED = 1; +const FILE_CHANGE_CHANGED = 2; +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2; + +/** + * A live spawned language-server process plus the `initializationOptions` to + * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by + * `createLspClient`. + */ +export interface LspServerHandle { + process: ChildProcessWithoutNullStreams; + initialization?: Record; +} + +interface ServerCapabilities { + textDocumentSync?: number | { change?: number }; + diagnosticProvider?: unknown; + [key: string]: unknown; +} + +interface DiagnosticRequestResult { + handled: boolean; + matched: boolean; + byFile: Map; +} + +interface CapabilityRegistration { + id: string; + method: string; + registerOptions?: { + identifier?: string; + workspaceDiagnostics?: boolean; + }; +} + +type DocumentDiagnosticReport = { + items?: Diagnostic[]; + relatedDocuments?: Record; +}; + +type WorkspaceDiagnosticReport = { + items?: { uri?: string; items?: Diagnostic[] }[]; +}; + +/** Public shape of a connected LSP client. */ +export interface LspClient { + readonly serverID: string; + readonly root: string; + readonly connection: MessageConnection; + /** + * Open (or re-sync) a file with the server. Returns the document version + * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching + * this exact sync. + */ + notifyOpen(path: string): Promise; + /** Snapshot of all known diagnostics keyed by absolute file path. */ + readonly diagnostics: Map; + /** Wait until diagnostics for `path` settle (push and/or pull). */ + waitForDiagnostics(request: { + path: string; + version: number; + mode?: "document" | "full"; + after?: number; + }): Promise; + /** Generic LSP request passthrough (hover, definition, references, …). */ + request(method: string, params: unknown): Promise; + /** Shut the connection and child process down. */ + shutdown(): Promise; +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +function getFilePath(uri: string): string | undefined { + if (!uri.startsWith("file://")) return undefined; + return fileURLToPath(uri); +} + +function getSyncKind(capabilities?: ServerCapabilities): number | undefined { + if (!capabilities) return undefined; + const sync = capabilities.textDocumentSync; + if (typeof sync === "number") return sync; + return sync?.change; +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; +} + +function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] { + const seen = new Set(); + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function configurationValue(settings: unknown, section?: string): unknown { + if (!section) return settings ?? null; + const result = section.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined; + return (acc as Record)[key]; + }, settings); + return result ?? null; +} + +/** + * Create and initialize an LSP client over a spawned server's stdio. + * + * Performs the full `initialize`/`initialized` handshake (with a 45s timeout), + * wires push (`textDocument/publishDiagnostics`) and pull + * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the + * `workspace/configuration`, `workspaceFolders`, and capability-registration + * requests servers commonly make, and returns a small client surface used by + * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`. + */ +export async function createLspClient(input: { + serverID: string; + server: LspServerHandle; + root: string; + directory: string; +}): Promise { + const { serverID, server, root, directory } = input; + + const connection = createMessageConnection( + new StreamMessageReader(server.process.stdout), + new StreamMessageWriter(server.process.stdin), + ); + + // Server stderr is routine for many tools (luau-lsp logs sourcemap status + // there). Keep it quiet unless debugging. + server.process.stderr?.on("data", () => { + /* swallowed — see opencode: stderr is mostly informational */ + }); + + // ─── Connection state ─── + const pushDiagnostics = new Map(); + const pullDiagnostics = new Map(); + const published = new Map(); + const diagnosticRegistrations = new Map(); + const registrationListeners = new Set<() => void>(); + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>(); + const files: Record = {}; + + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([ + ...(pushDiagnostics.get(filePath) ?? []), + ...(pullDiagnostics.get(filePath) ?? []), + ]); + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next); + for (const listener of diagnosticListeners) listener({ path: filePath, serverID }); + }; + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next); + }; + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener(); + }; + + // ─── Notification / request handlers ─── + connection.onNotification( + "textDocument/publishDiagnostics", + (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => { + const filePath = getFilePath(params.uri); + if (!filePath) return; + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }); + updatePushDiagnostics(filePath, params.diagnostics); + }, + ); + connection.onRequest("window/workDoneProgress/create", () => null); + connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => { + const items = params.items ?? []; + return items.map((item) => configurationValue(server.initialization, item.section)); + }); + connection.onRequest( + "client/registerCapability", + (params: { registrations?: CapabilityRegistration[] }) => { + const registrations = params.registrations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.set(registration.id, registration); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest( + "client/unregisterCapability", + (params: { unregisterations?: { id: string; method: string }[] }) => { + const registrations = params.unregisterations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.delete(registration.id); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest("workspace/workspaceFolders", () => [ + { name: "workspace", uri: pathToFileURL(root).href }, + ]); + connection.onRequest("workspace/diagnostic/refresh", () => null); + connection.listen(); + + // ─── Initialize handshake ─── + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { + rootUri: pathToFileURL(root).href, + processId: server.process.pid ?? null, + workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], + initializationOptions: { ...server.initialization }, + capabilities: { + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, + }, + }), + INITIALIZE_TIMEOUT_MS, + ); + + const syncKind = getSyncKind(initialized.capabilities); + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider); + + await connection.sendNotification("initialized", {}); + if (server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: server.initialization, + }); + } + + // ─── Pull-diagnostics helpers ─── + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((r) => r.handled); + const matched = results.some((r) => r.matched); + if (!handled) return { handled: false, matched: false }; + + const merged = new Map(); + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + merged.set(target, (merged.get(target) ?? []).concat(items)); + } + } + if (matched && !merged.has(filePath)) merged.set(filePath, []); + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)); + } + return { handled, matched }; + }; + + async function requestDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise { + const report = await withTimeout( + connection.sendRequest("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { uri: pathToFileURL(filePath).href }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + const empty: DiagnosticRequestResult = { + handled: false, + matched: false, + byFile: new Map(), + }; + if (!report) return empty; + + const byFile = new Map(); + const push = (target: string, items: Diagnostic[]) => { + byFile.set(target, (byFile.get(target) ?? []).concat(items)); + }; + let handled = false; + let matched = false; + if (Array.isArray(report.items)) { + push(filePath, report.items); + handled = true; + matched = true; + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri); + if (!relatedPath || !Array.isArray(related.items)) continue; + push(relatedPath, related.items); + handled = true; + matched = matched || relatedPath === filePath; + } + return { handled, matched, byFile }; + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise { + const report = await withTimeout( + connection.sendRequest("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + if (!report) return { handled: false, matched: false, byFile: new Map() }; + + const byFile = new Map(); + let matched = false; + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined; + if (!relatedPath || !Array.isArray(item.items)) continue; + byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items)); + matched = matched || relatedPath === filePath; + } + return { handled: true, matched, byFile }; + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics !== true, + ); + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + }; + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics === true, + ); + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + }; + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0); + + async function requestDiagnostics( + filePath: string, + requests: Promise[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false }; + const results: DiagnosticRequestResult[] = []; + return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => { + let pending = requests.length; + let resolved = false; + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return; + if (!force && !done(results)) return; + resolved = true; + resolvePromise(merged); + }; + for (const request of requests) { + request.then((result) => { + results.push(result); + pending -= 1; + const merged = mergeResults(filePath, results); + finish(merged); + if (pending === 0) finish(merged, true); + }); + } + }); + } + + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState(); + if (!state.supported) return { handled: false, matched: false }; + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ); + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState(); + const workspaceState = workspacePullState(); + if (!documentState.supported && !workspaceState.supported) { + return { handled: false, matched: false }; + } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((id) => + requestWorkspaceDiagnosticReport(filePath, id), + ), + ]), + ); + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false); + return new Promise((resolvePromise) => { + let finished = false; + let timer: ReturnType | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + registrationListeners.delete(listener); + resolvePromise(result); + }; + const listener = () => finish(true); + registrationListeners.add(listener); + timer = setTimeout(() => finish(false), timeout); + }); + } + + function waitForFreshPush(request: { + path: string; + version: number; + after: number; + timeout: number; + }) { + if (request.timeout <= 0) return Promise.resolve(false); + return new Promise((resolvePromise) => { + let finished = false; + let debounceTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; + let unsub: (() => void) | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + unsub?.(); + resolvePromise(result); + }; + const schedule = () => { + const hit = published.get(request.path); + if (!hit) return; + if (typeof hit.version === "number" && hit.version !== request.version) return; + if (hit.at < request.after && hit.version !== request.version) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout( + () => finish(true), + Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)), + ); + }; + timeoutTimer = setTimeout(() => finish(false), request.timeout); + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== serverID) return; + schedule(); + }; + diagnosticListeners.add(listener); + unsub = () => diagnosticListeners.delete(listener); + schedule(); + }); + } + + async function waitForDocumentDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path); + if (result.matched) return; + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + async function waitForFullDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path); + if (result.handled || result.matched) return; + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p)); + + // ─── Public surface ─── + const client: LspClient = { + serverID, + root, + connection, + async notifyOpen(path: string) { + const filePath = normalize(path); + const text = await readFile(filePath, "utf8"); + const languageId = languageIdForExtension(extname(filePath)); + const uri = pathToFileURL(filePath).href; + const document = files[filePath]; + + if (document !== undefined) { + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CHANGED }], + }); + const next = document.version + 1; + files[filePath] = { version: next, text }; + await connection.sendNotification("textDocument/didChange", { + textDocument: { uri, version: next }, + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], + }); + return next; + } + + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CREATED }], + }); + pushDiagnostics.delete(filePath); + pullDiagnostics.delete(filePath); + await connection.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId, version: 0, text }, + }); + files[filePath] = { version: 0, text }; + return 0; + }, + get diagnostics() { + const result = new Map(); + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)); + } + return result; + }, + async waitForDiagnostics(request) { + const normalizedPath = normalize(request.path); + if (request.mode === "document") { + await waitForDocumentDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + return; + } + await waitForFullDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + }, + async request(method: string, params: unknown): Promise { + return connection.sendRequest(method, params).catch(() => null); + }, + async shutdown() { + try { + connection.end(); + connection.dispose(); + } catch { + /* connection may already be closed */ + } + server.process.kill(); + }, + }; + + return client; +} diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts new file mode 100644 index 0000000..1ad4d0f --- /dev/null +++ b/packages/core/src/lsp/diagnostic.ts @@ -0,0 +1,41 @@ +import type { Diagnostic } from "vscode-languageserver-types"; + +/** + * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`. + * + * LSP positions are 0-based on the wire; we render them 1-based (editor-style) + * so they line up with what `read_file` shows and what editors report. + */ + +/** Max diagnostics rendered per file before truncating with a "… and N more". */ +const MAX_PER_FILE = 20; + +const SEVERITY_LABEL: Record = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", +}; + +/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */ +export function pretty(diagnostic: Diagnostic): string { + const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR"; + const line = diagnostic.range.start.line + 1; + const col = diagnostic.range.start.character + 1; + return `${severity} [${line}:${col}] ${diagnostic.message}`; +} + +/** + * Build a `` block for a file's ERROR-severity + * diagnostics, or `""` when there are none. Errors only — warnings/info/hints + * are intentionally omitted so the model is nudged toward the things that + * actually break the build (matching opencode's behavior). + */ +export function report(file: string, issues: Diagnostic[]): string { + const errors = issues.filter((item) => item.severity === 1); + if (errors.length === 0) return ""; + const limited = errors.slice(0, MAX_PER_FILE); + const more = errors.length - MAX_PER_FILE; + const suffix = more > 0 ? `\n... and ${more} more` : ""; + return `\n${limited.map(pretty).join("\n")}${suffix}\n`; +} diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts new file mode 100644 index 0000000..fd43c2f --- /dev/null +++ b/packages/core/src/lsp/index.ts @@ -0,0 +1,18 @@ +// LSP (Language Server Protocol) integration. +// +// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.]` +// block (see `LspServerConfig` in `../types`). There is no builtin server +// registry and no auto-download. The primary model-facing surface is +// diagnostics-on-write (the host passes a write hook that calls `touchFile` + +// `report`); an on-demand `lsp` tool exposes hover/definition/references too. + +export { + createLspClient, + type Diagnostic, + type LspClient, + type LspServerHandle, +} from "./client.js"; +export { pretty, report } from "./diagnostic.js"; +export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js"; +export { LspManager } from "./manager.js"; +export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js"; diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts new file mode 100644 index 0000000..3e9fe68 --- /dev/null +++ b/packages/core/src/lsp/language.ts @@ -0,0 +1,72 @@ +/** + * File-extension → LSP `languageId` map. + * + * The LSP `textDocument/didOpen` notification carries a `languageId` string + * that tells the server how to parse the document. This table is a trimmed + * port of opencode's `lsp/language.ts`, with one critical addition for this + * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension, + * which standard Lua tooling does not recognise — luau-lsp expects the + * `"luau"` languageId. + * + * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown + * extensions fall back to `"plaintext"` at the call site. + */ +export const LANGUAGE_EXTENSIONS: Record = { + // Luau (Roblox) — the reason this module exists. Keep first for visibility. + ".luau": "luau", + ".lua": "lua", + // A pragmatic subset of common languages, mirroring opencode's table so a + // user can point an arbitrary LSP server at this codebase and have the + // right languageId reported. + ".c": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".h": "c", + ".hpp": "cpp", + ".cs": "csharp", + ".css": "css", + ".dart": "dart", + ".go": "go", + ".html": "html", + ".htm": "html", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".kt": "kotlin", + ".kts": "kotlin", + ".md": "markdown", + ".markdown": "markdown", + ".php": "php", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".toml": "toml", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".vue": "vue", + ".xml": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".zig": "zig", +}; + +/** + * Resolve the LSP `languageId` for a file path's extension, falling back to + * `"plaintext"` when the extension is unknown. + */ +export function languageIdForExtension(extension: string): string { + return LANGUAGE_EXTENSIONS[extension] ?? "plaintext"; +} diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts new file mode 100644 index 0000000..db8b68e --- /dev/null +++ b/packages/core/src/lsp/manager.ts @@ -0,0 +1,220 @@ +import { extname } from "node:path"; +import { createLspClient, type Diagnostic, type LspClient } from "./client.js"; +import type { ResolvedLspServer } from "./server.js"; + +/** + * Process-wide owner of LSP client lifecycles. + * + * Clients are keyed by `root + serverID` and spawned lazily on the first file + * that matches a server's extensions, then reused. Concurrent spawns for the + * same key are de-duplicated via an in-flight map, and servers that fail to + * start are remembered in `broken` so we don't spawn-spam. Modeled on + * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery. + * + * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a + * tab's working-directory config (`resolveServersFromConfig`) and pass them in + * alongside the `root`. This keeps per-working-directory config out of the + * manager while letting it own all the long-lived processes for the process. + */ +export class LspManager { + private clients = new Map(); + private spawning = new Map>(); + private broken = new Set(); + + private key(root: string, serverID: string): string { + return `${root}\u0000${serverID}`; + } + + private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] { + const extension = extname(file) || file; + return servers.filter( + (server) => server.extensions.length === 0 || server.extensions.includes(extension), + ); + } + + /** + * True if any provided server is configured to attach to this file's + * extension (regardless of whether it has spawned yet). Used to decide + * whether an LSP operation is even applicable to a file. + */ + hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean { + return this.serversForFile(file, servers).length > 0; + } + + /** + * Get (spawning if needed) all clients that should attach to `file` at + * `root`. Spawn failures are swallowed (logged via `broken`) and simply + * yield fewer clients — callers degrade gracefully to "no diagnostics". + */ + async getClients(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + }): Promise { + const { file, root, servers } = input; + const matching = this.serversForFile(file, servers); + const result: LspClient[] = []; + + for (const server of matching) { + const key = this.key(root, server.id); + if (this.broken.has(key)) continue; + + const existing = this.clients.get(key); + if (existing) { + result.push(existing); + continue; + } + + const inflight = this.spawning.get(key); + if (inflight) { + const client = await inflight; + if (client) result.push(client); + continue; + } + + const task = this.spawn(server, root, key); + this.spawning.set(key, task); + task.finally(() => { + if (this.spawning.get(key) === task) this.spawning.delete(key); + }); + const client = await task; + if (client) result.push(client); + } + + return result; + } + + private async spawn( + server: ResolvedLspServer, + root: string, + key: string, + ): Promise { + let handle: ReturnType; + try { + handle = server.spawn(root); + } catch (err) { + this.broken.add(key); + console.warn( + `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } + + // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH) + // emits `error` on the child process; mark broken so we don't retry it. + handle.process.on("error", (err) => { + this.broken.add(key); + console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`); + }); + + try { + const client = await createLspClient({ + serverID: server.id, + server: handle, + root, + directory: root, + }); + // A racing caller may have created the same client; prefer the + // existing one and discard ours. + const existing = this.clients.get(key); + if (existing) { + await client.shutdown(); + return existing; + } + this.clients.set(key, client); + return client; + } catch (err) { + this.broken.add(key); + try { + handle.process.kill(); + } catch { + /* already dead */ + } + console.warn( + `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } + } + + /** + * Open/sync a file with its clients and (optionally) wait for diagnostics + * to settle. `mode: "document"` waits for the file's own diagnostics; + * `"full"` also waits on workspace diagnostics; omitted just syncs. + */ + async touchFile(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + mode?: "document" | "full"; + }): Promise { + const clients = await this.getClients(input); + await Promise.all( + clients.map(async (client) => { + const after = Date.now(); + const version = await client.notifyOpen(input.file); + if (!input.mode) return; + await client.waitForDiagnostics({ + path: input.file, + version, + mode: input.mode, + after, + }); + }), + ).catch((err) => { + console.warn( + `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + + /** + * Merged diagnostics for a single file across all of its clients, keyed by + * absolute file path. Includes related-file diagnostics a client surfaced + * (e.g. workspace pulls), so the result map may contain more than `file`. + */ + getDiagnostics(input: { + root: string; + servers: ResolvedLspServer[]; + file: string; + }): Record { + const results: Record = {}; + const matching = this.serversForFile(input.file, input.servers); + for (const server of matching) { + const client = this.clients.get(this.key(input.root, server.id)); + if (!client) continue; + for (const [path, diags] of client.diagnostics.entries()) { + results[path] = (results[path] ?? []).concat(diags); + } + } + return results; + } + + /** + * Run a positional LSP request (hover/definition/references/etc.) against + * every client for the file and flatten the (non-null) results. `line`/ + * `character` are 0-based here — the caller converts from editor 1-based. + */ + async request(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + method: string; + params: Record; + }): Promise { + const clients = await this.getClients(input); + const results = await Promise.all( + clients.map((client) => client.request(input.method, input.params)), + ); + return results.filter((r) => r !== null && r !== undefined); + } + + /** Shut down every live client and clear all state. */ + async shutdownAll(): Promise { + const clients = [...this.clients.values()]; + this.clients.clear(); + this.spawning.clear(); + this.broken.clear(); + await Promise.all(clients.map((client) => client.shutdown().catch(() => {}))); + } +} diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts new file mode 100644 index 0000000..1fb002e --- /dev/null +++ b/packages/core/src/lsp/server.ts @@ -0,0 +1,68 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import type { LspServerConfig } from "../types/index.js"; +import type { LspServerHandle } from "./client.js"; + +/** + * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml` + * `[lsp.]` entry. Config-driven only — dispatch ships no builtin server + * registry and performs no auto-download (unlike opencode). The declared + * executable (`command[0]`) must already be on PATH. + */ +export interface ResolvedLspServer { + id: string; + /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */ + extensions: string[]; + /** Launch the server over stdio rooted at `root`. */ + spawn(root: string): LspServerHandle; +} + +/** + * Spawn a child process for an LSP server over stdio. Inherits `process.env` + * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate) + * and merges any `env` from the server config on top. + */ +function spawnServer( + command: string[], + cwd: string, + env: Record | undefined, + initialization: Record | undefined, +): LspServerHandle { + const [cmd, ...args] = command; + if (!cmd) throw new Error("LSP server command is empty"); + const proc = spawn(cmd, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + return { + process: proc, + ...(initialization ? { initialization } : {}), + }; +} + +/** + * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable + * servers. Disabled entries are dropped. Entries with no `command`/`extensions` + * are skipped defensively (the config validator already enforces these, but we + * guard here too so a hand-built config object can't crash the manager). + */ +export function resolveServersFromConfig( + lsp: Record | undefined, +): ResolvedLspServer[] { + if (!lsp) return []; + const servers: ResolvedLspServer[] = []; + for (const [id, entry] of Object.entries(lsp)) { + if (entry.disabled) continue; + if (!entry.command || entry.command.length === 0) continue; + if (!entry.extensions || entry.extensions.length === 0) continue; + const command = entry.command; + const env = entry.env; + const initialization = entry.initialization; + servers.push({ + id, + extensions: entry.extensions, + spawn: (root: string) => spawnServer(command, root, env, initialization), + }); + } + return servers; +} diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts new file mode 100644 index 0000000..3842cd4 --- /dev/null +++ b/packages/core/src/tools/lsp.ts @@ -0,0 +1,135 @@ +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { z } from "zod"; +import { report as reportDiagnostics } from "../lsp/diagnostic.js"; +import type { LspManager } from "../lsp/manager.js"; +import type { ResolvedLspServer } from "../lsp/server.js"; +import type { ToolDefinition } from "../types/index.js"; + +const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const; +type Operation = (typeof OPERATIONS)[number]; + +/** + * Context the LSP tool needs from the host: the live manager, the tab's + * effective working directory (used as the LSP `root`), and the servers + * resolved from that directory's `dispatch.toml`. + */ +export interface LspToolContext { + manager: LspManager; + workingDirectory: string; + servers: ResolvedLspServer[]; +} + +/** + * On-demand LSP query tool. Exposes diagnostics plus the navigation + * capabilities (hover/definition/references/documentSymbol) for a file at a + * position. Gated behind `perm_lsp` by the host. + * + * Coordinates are **1-based** in this tool's API (editor-style, matching what + * `read_file` shows); they are converted to the LSP wire's 0-based positions + * before the request. + */ +export function createLspTool(getContext: () => LspToolContext): ToolDefinition { + return { + name: "lsp", + description: + "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " + + "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " + + "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " + + "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " + + "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.", + parameters: z.object({ + operation: z.enum(OPERATIONS).describe("The LSP operation to perform"), + path: z.string().describe("Path to the file, relative to the working directory"), + line: z + .number() + .int() + .min(1) + .optional() + .describe( + "Line number, 1-based (as shown in editors). Required for hover/definition/references.", + ), + character: z + .number() + .int() + .min(1) + .optional() + .describe( + "Character/column, 1-based (as shown in editors). Required for hover/definition/references.", + ), + }), + execute: async (args: Record): Promise => { + const { manager, workingDirectory, servers } = getContext(); + const operation = args.operation as Operation; + const pathArg = typeof args.path === "string" ? args.path : ""; + if (!pathArg) return "Error: 'path' is required."; + + const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg); + + if (servers.length === 0) { + return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml."; + } + if (!manager.hasServerForFile(file, servers)) { + return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`; + } + + // Sync the file so the server has current content, then act. + await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" }); + + if (operation === "diagnostics") { + const all = manager.getDiagnostics({ root: workingDirectory, servers, file }); + const block = reportDiagnostics(file, all[file] ?? []); + return block || `No errors reported for ${pathArg}.`; + } + + if (operation === "documentSymbol") { + const uri = pathToFileURL(file).href; + const results = await manager.request({ + file, + root: workingDirectory, + servers, + method: "textDocument/documentSymbol", + params: { textDocument: { uri } }, + }); + const flat = results.flat().filter(Boolean); + return flat.length === 0 + ? `No symbols found in ${pathArg}.` + : JSON.stringify(flat, null, 2); + } + + // Positional operations need line + character. + const line = typeof args.line === "number" ? Math.floor(args.line) : undefined; + const character = typeof args.character === "number" ? Math.floor(args.character) : undefined; + if (line === undefined || character === undefined) { + return `Error: '${operation}' requires both 'line' and 'character' (1-based).`; + } + + const uri = pathToFileURL(file).href; + // Convert editor 1-based → LSP wire 0-based. + const position = { line: line - 1, character: character - 1 }; + const method = + operation === "hover" + ? "textDocument/hover" + : operation === "definition" + ? "textDocument/definition" + : "textDocument/references"; + const params: Record = { + textDocument: { uri }, + position, + ...(operation === "references" ? { context: { includeDeclaration: true } } : {}), + }; + + const results = await manager.request({ + file, + root: workingDirectory, + servers, + method, + params, + }); + const flat = results.flat().filter(Boolean); + return flat.length === 0 + ? `No results found for ${operation} at ${pathArg}:${line}:${character}.` + : JSON.stringify(flat, null, 2); + }, + }; +} diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index aa69c86..8a73352 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -4,7 +4,21 @@ import { z } from "zod"; import type { ToolDefinition } from "../types/index.js"; import { canonicalize } from "./path-utils.js"; -export function createWriteFileTool(workingDirectory: string): ToolDefinition { +/** + * Optional hook invoked AFTER a successful write, with the canonicalized + * absolute path of the file just written. Its returned string (when non-empty) + * is appended to the tool result. This is how LSP diagnostics are surfaced + * back to the model on write without coupling `@dispatch/core`'s tools to the + * API layer or the LSP manager — the host wires an implementation that touches + * the file through the LSP and formats any diagnostics. Errors thrown here are + * swallowed so a flaky LSP never fails the write itself. + */ +export type AfterWriteHook = (absolutePath: string) => Promise; + +export function createWriteFileTool( + workingDirectory: string, + onAfterWrite?: AfterWriteHook, +): ToolDefinition { return { name: "write_file", description: "Write content to a file relative to the working directory.", @@ -31,10 +45,22 @@ export function createWriteFileTool(workingDirectory: string): ToolDefinition { try { await mkdir(dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, content, "utf8"); - return `Successfully wrote to "${filePath}".`; } catch (err) { return `Error writing file: ${err instanceof Error ? err.message : String(err)}`; } + + let result = `Successfully wrote to "${filePath}".`; + // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a + // hook failure turn a successful write into an error. + if (onAfterWrite) { + try { + const extra = await onAfterWrite(absolutePath); + if (extra) result += `\n\n${extra}`; + } catch { + /* ignore — diagnostics are advisory */ + } + } + return result; }, }; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a22b2b7..607b27d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -435,6 +435,55 @@ export interface AgentConfig { export interface DispatchConfig { keys?: KeyDefinition[]; permissions: Record>; + /** + * Language Server Protocol servers, keyed by an arbitrary server id (e.g. + * `"luau-lsp"`). Project-scoped: read from the `dispatch.toml` in a tab's + * effective working directory and re-consulted when that directory (or the + * config) changes. Config-driven only — there is no builtin server registry + * and no auto-download; the declared `command[0]` must be on PATH. + */ + lsp?: Record; +} + +/** + * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.]` + * block. Mirrors opencode's custom-server schema so the Roblox Luau config + * (and any other server) is portable between the two. + * + * Example (`dispatch.toml`): + * ```toml + * [lsp.luau-lsp] + * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"] + * extensions = [".luau"] + * + * [lsp.luau-lsp.initialization.luau-lsp.platform] + * type = "roblox" + * ``` + */ +export interface LspServerConfig { + /** + * Argv to launch the server over stdio. `command[0]` is the executable + * (resolved via PATH); the rest are arguments. Required for every non- + * disabled entry. + */ + command: string[]; + /** + * File extensions (with leading dot, e.g. `".luau"`) this server attaches + * to. Required for custom servers — without it the client never knows which + * files should activate the server. + */ + extensions: string[]; + /** Extra environment variables merged onto `process.env` for the child. */ + env?: Record; + /** + * `initializationOptions` forwarded verbatim in the LSP `initialize` + * request (and echoed back for `workspace/configuration` / + * `didChangeConfiguration`). For luau-lsp this carries the + * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block. + */ + initialization?: Record; + /** When true, the entry is parsed but skipped (no server launched). */ + disabled?: boolean; } export interface KeyDefinition { diff --git a/packages/core/tests/config/lsp-schema.test.ts b/packages/core/tests/config/lsp-schema.test.ts new file mode 100644 index 0000000..2b71cc2 --- /dev/null +++ b/packages/core/tests/config/lsp-schema.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { validateConfig } from "../../src/config/schema.js"; + +describe("config schema — [lsp] block", () => { + it("parses a valid custom server entry", () => { + const { config, errors } = validateConfig({ + permissions: {}, + lsp: { + "luau-lsp": { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { "luau-lsp": { platform: { type: "roblox" } } }, + }, + }, + }); + expect(errors).toHaveLength(0); + expect(config.lsp).toBeDefined(); + const entry = config.lsp?.["luau-lsp"]; + expect(entry?.command).toEqual(["luau-lsp", "lsp"]); + expect(entry?.extensions).toEqual([".luau"]); + expect(entry?.initialization).toEqual({ + "luau-lsp": { platform: { type: "roblox" } }, + }); + }); + + it("preserves env and nested initialization verbatim", () => { + const { config } = validateConfig({ + permissions: {}, + lsp: { + "luau-lsp": { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + env: { PATH: "/custom/bin" }, + initialization: { + "luau-lsp": { + sourcemap: { enabled: true, autogenerate: true }, + diagnostics: { strictDatamodelTypes: false }, + }, + }, + }, + }, + }); + const entry = config.lsp?.["luau-lsp"]; + expect(entry?.env).toEqual({ PATH: "/custom/bin" }); + expect(entry?.initialization).toEqual({ + "luau-lsp": { + sourcemap: { enabled: true, autogenerate: true }, + diagnostics: { strictDatamodelTypes: false }, + }, + }); + }); + + it("rejects a custom server missing command", () => { + const { config, errors } = validateConfig({ + permissions: {}, + lsp: { broken: { extensions: [".luau"] } }, + }); + expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true); + expect(config.lsp).toBeUndefined(); + }); + + it("rejects a custom server missing extensions", () => { + const { errors } = validateConfig({ + permissions: {}, + lsp: { broken: { command: ["x"] } }, + }); + expect(errors.some((e) => e.path === "lsp.broken.extensions")).toBe(true); + }); + + it("rejects an empty command array", () => { + const { errors } = validateConfig({ + permissions: {}, + lsp: { broken: { command: [], extensions: [".luau"] } }, + }); + expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true); + }); + + it("keeps a disabled entry without requiring command/extensions", () => { + const { config, errors } = validateConfig({ + permissions: {}, + lsp: { "luau-lsp": { disabled: true } }, + }); + expect(errors).toHaveLength(0); + expect(config.lsp?.["luau-lsp"]?.disabled).toBe(true); + }); + + it("skips a malformed entry but keeps valid siblings", () => { + const { config, errors } = validateConfig({ + permissions: {}, + lsp: { + good: { command: ["a"], extensions: [".luau"] }, + bad: { extensions: [".luau"] }, + }, + }); + expect(config.lsp?.good).toBeDefined(); + expect(config.lsp?.bad).toBeUndefined(); + expect(errors.length).toBeGreaterThan(0); + }); + + it("omits lsp entirely when not present", () => { + const { config, errors } = validateConfig({ permissions: {} }); + expect(errors).toHaveLength(0); + expect(config.lsp).toBeUndefined(); + }); + + it("flags a non-object lsp value", () => { + const { errors } = validateConfig({ permissions: {}, lsp: "nope" }); + expect(errors.some((e) => e.path === "lsp")).toBe(true); + }); +}); diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js new file mode 100644 index 0000000..d771ebd --- /dev/null +++ b/packages/core/tests/fixture/lsp/fake-lsp-server.js @@ -0,0 +1,195 @@ +// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP +// client without a real language server binary. Ported from opencode's +// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and +// manager exercise: initialize, didOpen/didChange, push + pull diagnostics). +// +// Test hooks (custom JSON-RPC methods the test driver can call): +// test/get-initialize-params → returns the params sent to `initialize` +// test/get-last-change → returns the last `didChange` params +// test/publish-diagnostics → forwards a `publishDiagnostics` push +// test/configure-pull-diagnostics → sets up pull-diagnostic responses +// test/get-diagnostic-request-count→ how many pull requests were received + +let nextId = 1; +let readBuffer = Buffer.alloc(0); +let lastChange = null; +let initializeParams = null; +let diagnosticRequestCount = 0; +let registeredCapability = false; +let pullConfig = { + registerOn: undefined, + registrations: [], + documentDiagnostics: [], + workspaceDiagnostics: [], + hasDiagnosticProvider: false, +}; + +function encode(message) { + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`; + return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]); +} + +function decodeFrames(buffer) { + const results = []; + while (true) { + const idx = buffer.indexOf("\r\n\r\n"); + if (idx === -1) break; + const header = buffer.slice(0, idx).toString("utf8"); + const match = /Content-Length:\s*(\d+)/i.exec(header); + const length = match ? parseInt(match[1], 10) : 0; + const bodyStart = idx + 4; + const bodyEnd = bodyStart + length; + if (buffer.length < bodyEnd) break; + results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + } + return { messages: results, rest: buffer }; +} + +function send(message) { + process.stdout.write(encode(message)); +} +function sendRequest(method, params) { + const id = nextId++; + send({ jsonrpc: "2.0", id, method, params }); + return id; +} +function sendResponse(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +function sendNotification(method, params) { + send({ jsonrpc: "2.0", method, params }); +} + +function maybeRegister(method) { + if (pullConfig.registerOn !== method || registeredCapability) return; + registeredCapability = true; + sendRequest("client/registerCapability", { + registrations: pullConfig.registrations.map((registration, index) => ({ + id: registration.id ?? `pull-${index}`, + method: registration.method ?? "textDocument/diagnostic", + registerOptions: registration.registerOptions ?? registration, + })), + }); +} + +function handle(raw) { + let data; + try { + data = JSON.parse(raw); + } catch { + return; + } + + if (data.method === "initialize") { + initializeParams = data.params; + sendResponse(data.id, { + capabilities: { + textDocumentSync: { change: 2, openClose: true }, + ...(pullConfig.hasDiagnosticProvider + ? { + diagnosticProvider: { + identifier: "fake", + interFileDependencies: false, + workspaceDiagnostics: false, + }, + } + : {}), + }, + }); + return; + } + + if (data.method === "test/get-initialize-params") { + sendResponse(data.id, initializeParams); + return; + } + + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { + return; + } + + if (data.method === "textDocument/didOpen") { + maybeRegister("didOpen"); + return; + } + + if (data.method === "textDocument/didChange") { + lastChange = data.params; + maybeRegister("didChange"); + return; + } + + if (data.method === "workspace/didChangeWatchedFiles") { + return; + } + + if (data.method === "test/configure-pull-diagnostics") { + pullConfig = { + registerOn: data.params?.registerOn, + registrations: data.params?.registrations ?? [], + documentDiagnostics: data.params?.documentDiagnostics ?? [], + workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], + hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false, + }; + registeredCapability = false; + sendResponse(data.id, null); + return; + } + + if (data.method === "test/publish-diagnostics") { + sendNotification("textDocument/publishDiagnostics", data.params); + sendResponse(data.id, null); + return; + } + + if (data.method === "test/get-last-change") { + sendResponse(data.id, lastChange); + return; + } + + if (data.method === "test/get-diagnostic-request-count") { + sendResponse(data.id, diagnosticRequestCount); + return; + } + + if (data.method === "textDocument/diagnostic") { + diagnosticRequestCount += 1; + sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics }); + return; + } + + if (data.method === "workspace/diagnostic") { + diagnosticRequestCount += 1; + sendResponse(data.id, { items: pullConfig.workspaceDiagnostics }); + return; + } + + if (data.method === "textDocument/hover") { + sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } }); + return; + } + + if (data.method === "textDocument/definition") { + sendResponse(data.id, [ + { + uri: data.params?.textDocument?.uri, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + }, + ]); + return; + } + + // Default: respond null to any other request so the client never hangs. + if (typeof data.id !== "undefined") { + sendResponse(data.id, null); + } +} + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]); + const { messages, rest } = decodeFrames(readBuffer); + readBuffer = rest; + for (const message of messages) handle(message); +}); diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts new file mode 100644 index 0000000..8daf8ab --- /dev/null +++ b/packages/core/tests/lsp/client.test.ts @@ -0,0 +1,146 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js"; + +const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js"); + +function spawnFakeServer(): LspServerHandle { + const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" }); + return { process: proc as LspServerHandle["process"] }; +} + +const ERROR_DIAG: Diagnostic = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "fake type error", + source: "Fake", +}; + +describe("lsp/client (fake server)", () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-")); + }); + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it("completes the initialize handshake and forwards initializationOptions", async () => { + const handle = spawnFakeServer(); + handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } }; + const client = await createLspClient({ + serverID: "fake", + server: handle, + root: workDir, + directory: workDir, + }); + + const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>( + "test/get-initialize-params", + {}, + ); + expect(params.initializationOptions).toEqual({ + "luau-lsp": { platform: { type: "roblox" } }, + }); + await client.shutdown(); + }); + + it("opens a file and receives push diagnostics", async () => { + const handle = spawnFakeServer(); + const client = await createLspClient({ + serverID: "fake", + server: handle, + root: workDir, + directory: workDir, + }); + + const file = join(workDir, "a.luau"); + await writeFile(file, "local x = 1\n"); + const version = await client.notifyOpen(file); + expect(version).toBe(0); + + // Drive a push from the fake server, then assert it lands in the map. + await client.connection.sendRequest("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + diagnostics: [ERROR_DIAG], + }); + await new Promise((r) => setTimeout(r, 50)); + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error"); + await client.shutdown(); + }); + + it("bumps the document version on re-open (didChange)", async () => { + const handle = spawnFakeServer(); + const client = await createLspClient({ + serverID: "fake", + server: handle, + root: workDir, + directory: workDir, + }); + const file = join(workDir, "a.luau"); + await writeFile(file, "local x = 1\n"); + expect(await client.notifyOpen(file)).toBe(0); + await writeFile(file, "local x = 2\n"); + expect(await client.notifyOpen(file)).toBe(1); + + const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>( + "test/get-last-change", + {}, + ); + expect(lastChange?.textDocument?.version).toBe(1); + await client.shutdown(); + }); + + it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => { + const handle = spawnFakeServer(); + const client = await createLspClient({ + serverID: "fake", + server: handle, + root: workDir, + directory: workDir, + }); + // Tell the fake server (before initialize? no — it persists) to answer + // pull requests. We configure AFTER connect; the static provider flag is + // read at initialize, so this test exercises the dynamic registration + // path instead. + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }], + documentDiagnostics: [ERROR_DIAG], + }); + + const file = join(workDir, "a.luau"); + await writeFile(file, "bad\n"); + const version = await client.notifyOpen(file); + await client.waitForDiagnostics({ path: file, version, mode: "document" }); + + expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true); + await client.shutdown(); + }); + + it("request() passes through to the server (hover)", async () => { + const handle = spawnFakeServer(); + const client = await createLspClient({ + serverID: "fake", + server: handle, + root: workDir, + directory: workDir, + }); + const file = join(workDir, "a.luau"); + await writeFile(file, "local x = 1\n"); + await client.notifyOpen(file); + const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + }); + expect(hover?.contents?.value).toBe("fake hover"); + await client.shutdown(); + }); +}); diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts new file mode 100644 index 0000000..93ffde9 --- /dev/null +++ b/packages/core/tests/lsp/diagnostic.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { pretty, report } from "../../src/lsp/diagnostic.js"; + +function diag(partial: Partial & { message: string }): Diagnostic { + return { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 1, + ...partial, + }; +} + +describe("lsp/diagnostic", () => { + describe("pretty", () => { + it("renders 1-based line/col with severity label", () => { + const out = pretty( + diag({ + message: "Expected number", + range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } }, + }), + ); + expect(out).toBe("ERROR [5:3] Expected number"); + }); + + it("maps severities to labels", () => { + expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /); + expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /); + expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /); + }); + + it("defaults missing severity to ERROR", () => { + expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /); + }); + }); + + describe("report", () => { + it("returns empty string when there are no errors", () => { + expect(report("a.luau", [])).toBe(""); + // Warnings only → still empty (errors-only). + expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe(""); + }); + + it("wraps errors in a block", () => { + const out = report("src/a.luau", [diag({ message: "boom" })]); + expect(out).toContain(''); + expect(out).toContain("ERROR [1:1] boom"); + expect(out).toContain(""); + }); + + it("filters out non-error severities", () => { + const out = report("a.luau", [ + diag({ message: "err" }), + diag({ message: "warn", severity: 2 }), + ]); + expect(out).toContain("err"); + expect(out).not.toContain("warn"); + }); + + it("caps at 20 and notes the remainder", () => { + const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` })); + const out = report("a.luau", issues); + expect(out).toContain("... and 5 more"); + expect(out).toContain("e0"); + expect(out).not.toContain("e24"); + }); + }); +}); diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts new file mode 100644 index 0000000..381435b --- /dev/null +++ b/packages/core/tests/lsp/luau-lsp.smoke.test.ts @@ -0,0 +1,63 @@ +import { execSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LspManager } from "../../src/lsp/manager.js"; +import { resolveServersFromConfig } from "../../src/lsp/server.js"; + +/** + * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically + * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's + * platform-guarded launch test. When the binary IS present, it proves the + * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics. + */ +function hasLuauLsp(): boolean { + try { + execSync("luau-lsp --version", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const RUN = hasLuauLsp(); + +describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => { + let root: string; + let manager: LspManager; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-")); + manager = new LspManager(); + }); + afterEach(async () => { + await manager.shutdownAll(); + await rm(root, { recursive: true, force: true }); + }); + + it("reports a real type error for a bad .luau file", async () => { + const servers = resolveServersFromConfig({ + "luau-lsp": { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { + "luau-lsp": { + platform: { type: "roblox" }, + diagnostics: { strictDatamodelTypes: false }, + }, + }, + }, + }); + + const file = join(root, "bad.luau"); + await writeFile(file, 'local x: number = "not a number"\nprint(x)\n'); + + await manager.touchFile({ file, root, servers, mode: "document" }); + const diagnostics = manager.getDiagnostics({ root, servers, file }); + const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n"); + + expect(messages.length).toBeGreaterThan(0); + expect(messages.toLowerCase()).toContain("number"); + }, 60_000); +}); diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts new file mode 100644 index 0000000..e720413 --- /dev/null +++ b/packages/core/tests/lsp/manager.test.ts @@ -0,0 +1,120 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { LspManager } from "../../src/lsp/manager.js"; +import type { ResolvedLspServer } from "../../src/lsp/server.js"; + +const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js"); + +function makeServer(id: string, extensions: string[]) { + const counter = { count: 0 }; + const server: ResolvedLspServer = { + id, + extensions, + spawn() { + counter.count += 1; + const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" }); + return { process: proc as never }; + }, + }; + return { server, counter }; +} + +describe("lsp/manager (fake server)", () => { + let root: string; + let manager: LspManager; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-")); + manager = new LspManager(); + }); + afterEach(async () => { + await manager.shutdownAll(); + await rm(root, { recursive: true, force: true }); + }); + + it("hasServerForFile matches by extension", () => { + const { server } = makeServer("fake", [".luau"]); + expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true); + expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false); + }); + + it("spawns lazily and reuses the client across calls", async () => { + const { server, counter } = makeServer("fake", [".luau"]); + const file = join(root, "a.luau"); + await writeFile(file, "local x = 1\n"); + + const c1 = await manager.getClients({ file, root, servers: [server] }); + const c2 = await manager.getClients({ file, root, servers: [server] }); + expect(c1).toHaveLength(1); + expect(c2).toHaveLength(1); + expect(c1[0]).toBe(c2[0]); + expect(counter.count).toBe(1); + }); + + it("does not spawn for a non-matching extension", async () => { + const { server, counter } = makeServer("fake", [".luau"]); + const file = join(root, "a.ts"); + await writeFile(file, "const x = 1\n"); + const clients = await manager.getClients({ file, root, servers: [server] }); + expect(clients).toHaveLength(0); + expect(counter.count).toBe(0); + }); + + it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => { + const { server } = makeServer("fake", [".luau"]); + const file = join(root, "a.luau"); + await writeFile(file, "bad code\n"); + + await manager.touchFile({ file, root, servers: [server] }); + const [client] = await manager.getClients({ file, root, servers: [server] }); + // Drive a push through the fake server. + const diag: Diagnostic = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, + severity: 1, + message: "manager error", + }; + await client.connection.sendRequest("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + diagnostics: [diag], + }); + await new Promise((r) => setTimeout(r, 50)); + + const result = manager.getDiagnostics({ root, servers: [server], file }); + expect(result[file]?.[0]?.message).toBe("manager error"); + }); + + it("request() forwards to clients and flattens results", async () => { + const { server } = makeServer("fake", [".luau"]); + const file = join(root, "a.luau"); + await writeFile(file, "local x = 1\n"); + await manager.touchFile({ file, root, servers: [server] }); + + const results = await manager.request({ + file, + root, + servers: [server], + method: "textDocument/definition", + params: { + textDocument: { uri: pathToFileURL(file).href }, + position: { line: 0, character: 6 }, + }, + }); + expect(results.length).toBeGreaterThan(0); + }); + + it("shutdownAll clears state so the next call respawns", async () => { + const { server, counter } = makeServer("fake", [".luau"]); + const file = join(root, "a.luau"); + await writeFile(file, "local x = 1\n"); + await manager.getClients({ file, root, servers: [server] }); + expect(counter.count).toBe(1); + await manager.shutdownAll(); + await manager.getClients({ file, root, servers: [server] }); + expect(counter.count).toBe(2); + }); +}); diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts new file mode 100644 index 0000000..bdaf83d --- /dev/null +++ b/packages/core/tests/lsp/server.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveServersFromConfig } from "../../src/lsp/server.js"; + +describe("lsp/server resolveServersFromConfig", () => { + it("returns [] for undefined config", () => { + expect(resolveServersFromConfig(undefined)).toEqual([]); + }); + + it("resolves a server entry with id + extensions", () => { + const servers = resolveServersFromConfig({ + "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] }, + }); + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("luau-lsp"); + expect(servers[0]?.extensions).toEqual([".luau"]); + expect(typeof servers[0]?.spawn).toBe("function"); + }); + + it("skips disabled entries", () => { + const servers = resolveServersFromConfig({ + "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true }, + }); + expect(servers).toEqual([]); + }); + + it("skips entries with empty command or extensions", () => { + const servers = resolveServersFromConfig({ + noCommand: { command: [], extensions: [".luau"] }, + noExt: { command: ["x"], extensions: [] }, + }); + expect(servers).toEqual([]); + }); + + it("resolves multiple servers", () => { + const servers = resolveServersFromConfig({ + a: { command: ["a"], extensions: [".luau"] }, + b: { command: ["b"], extensions: [".lua"] }, + }); + expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]); + }); +}); diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts new file mode 100644 index 0000000..7f26522 --- /dev/null +++ b/packages/core/tests/tools/lsp-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LspManager } from "../../src/lsp/manager.js"; +import type { ResolvedLspServer } from "../../src/lsp/server.js"; +import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js"; + +const SERVER: ResolvedLspServer = { + id: "luau-lsp", + extensions: [".luau"], + spawn: () => ({ process: {} as never }), +}; + +function makeManager(overrides: Partial = {}): LspManager { + return { + hasServerForFile: vi.fn(() => true), + touchFile: vi.fn(async () => {}), + getDiagnostics: vi.fn(() => ({})), + request: vi.fn(async () => []), + getClients: vi.fn(async () => []), + shutdownAll: vi.fn(async () => {}), + ...overrides, + } as unknown as LspManager; +} + +function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext { + return () => ({ manager, workingDirectory: "/work", servers }); +} + +describe("createLspTool", () => { + it("exposes the expected schema/name", () => { + const tool = createLspTool(ctx(makeManager())); + expect(tool.name).toBe("lsp"); + expect(tool.description).toMatch(/luau-lsp/i); + }); + + it("errors when no servers are configured", async () => { + const tool = createLspTool(ctx(makeManager(), [])); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(out).toMatch(/no LSP servers are configured/i); + }); + + it("errors when no server matches the file", async () => { + const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "diagnostics", path: "a.ts" }); + expect(out).toMatch(/no configured LSP server matches/i); + }); + + it("diagnostics: touches the file then reports errors", async () => { + const touchFile = vi.fn(async () => {}); + const getDiagnostics = vi.fn(() => ({ + "/work/a.luau": [ + { + range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } }, + severity: 1, + message: "bad type", + }, + ], + })); + const manager = makeManager({ + touchFile: touchFile as never, + getDiagnostics: getDiagnostics as never, + }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(touchFile).toHaveBeenCalledOnce(); + expect(out).toContain("ERROR [3:2] bad type"); + }); + + it("diagnostics: reports clean when no errors", async () => { + const tool = createLspTool(ctx(makeManager())); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(out).toMatch(/No errors reported/i); + }); + + it("hover: requires line and character", async () => { + const tool = createLspTool(ctx(makeManager())); + const out = await tool.execute({ operation: "hover", path: "a.luau" }); + expect(out).toMatch(/requires both 'line' and 'character'/i); + }); + + it("hover: converts 1-based coords to 0-based on the wire", async () => { + const request = vi.fn(async () => [{ contents: "hi" }]); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 }); + expect(request).toHaveBeenCalledOnce(); + const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } }; + expect(arg.method).toBe("textDocument/hover"); + expect(arg.params.position).toEqual({ line: 4, character: 2 }); + }); + + it("references: includes declaration context", async () => { + const request = vi.fn(async () => []); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 }); + const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } }; + expect(arg.params.context).toEqual({ includeDeclaration: true }); + }); + + it("documentSymbol: does not require a position", async () => { + const request = vi.fn(async () => [{ name: "foo" }]); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" }); + const arg = request.mock.calls[0]?.[0] as { method: string }; + expect(arg.method).toBe("textDocument/documentSymbol"); + expect(out).toContain("foo"); + }); +}); diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts index f071e12..0dedbfc 100644 --- a/packages/core/tests/tools/write-file.test.ts +++ b/packages/core/tests/tools/write-file.test.ts @@ -103,4 +103,50 @@ describe("write_file tool", () => { expect(entries).toEqual([]); }); }); + + describe("onAfterWrite hook", () => { + it("appends the hook's returned string to a successful write", async () => { + const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result).toMatch(/successfully wrote/i); + expect(result).toContain("DIAGNOSTICS for"); + expect(result).toContain(join(workDir, "a.luau")); + }); + + it("does not append when the hook returns empty string", async () => { + const tool = createWriteFileTool(workDir, async () => ""); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/); + }); + + it("does not run the hook when the write is blocked (traversal)", async () => { + let called = false; + const tool = createWriteFileTool(workDir, async () => { + called = true; + return "should not appear"; + }); + const result = await tool.execute({ path: "../evil.txt", content: "bad" }); + expect(result).toMatch(/outside the working directory/i); + expect(called).toBe(false); + }); + + it("swallows hook errors so a throwing hook never fails the write", async () => { + const tool = createWriteFileTool(workDir, async () => { + throw new Error("lsp blew up"); + }); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result).toMatch(/successfully wrote/i); + expect(result).not.toContain("lsp blew up"); + }); + + it("passes the canonical absolute path to the hook", async () => { + let seen = ""; + const tool = createWriteFileTool(workDir, async (abs) => { + seen = abs; + return ""; + }); + await tool.execute({ path: "nested/b.luau", content: "x" }); + expect(seen).toBe(join(workDir, "nested/b.luau")); + }); + }); }); -- cgit v1.2.3 From 94ce3401cd65ff3f58c134c73cb3f816b7142093 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 19:02:43 +0900 Subject: feat(api): fall back to next port (3000→3010) when the port is in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API server bound a fixed port (3000) and died with EADDRINUSE when it was taken — painful when running multiple dispatch instances (e.g. testing several feature branches at once). Replace the static default export with an explicit Bun.serve retry loop that increments the port by one on EADDRINUSE, from START_PORT (PORT env or 3000) up to MAX_PORT (3010), logging the chosen port and a hint to repoint the frontend's API URL on a bump. Guarded by import.meta.main so importing the module (for `app`) never binds a port. Frontend unchanged — set its API URL manually when a bump occurs. --- packages/api/src/index.ts | 61 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 6 deletions(-) (limited to 'packages') diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a0ad025..478abe0 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -70,9 +70,58 @@ app.get( export { app }; -export default { - port: Number(process.env.PORT) || 3000, - idleTimeout: 60, - fetch: app.fetch, - websocket, -}; +// Starting port (overridable via PORT) and the inclusive ceiling we will bump +// up to when a port is already in use. If 3000 is taken we try 3001, 3002, … +// up to MAX_PORT, so multiple dispatch instances (e.g. testing several +// features at once) can coexist without manually juggling ports. The frontend +// defaults to :3000 — point it at the chosen port via the in-app API-URL +// field / VITE_API_URL when a bump happens. +const START_PORT = Number(process.env.PORT) || 3000; +const MAX_PORT = 3010; + +/** + * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a + * free port is found or MAX_PORT is exceeded. Bun's `Bun.serve` throws + * synchronously when the port is taken, so we can catch and retry. Returns the + * live server (whose `.port` reflects the port actually bound). + */ +function serveWithPortFallback() { + let lastError: unknown; + for (let port = START_PORT; port <= MAX_PORT; port++) { + try { + const server = Bun.serve({ + port, + idleTimeout: 60, + fetch: app.fetch, + websocket, + }); + if (port !== START_PORT) { + console.warn( + `dispatch: port ${START_PORT} in use — bound to ${port} instead. ` + + `Set the frontend's API URL to http://localhost:${port}.`, + ); + } + console.log(`dispatch: API listening on http://localhost:${server.port}`); + return server; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "EADDRINUSE") { + lastError = err; + continue; + } + throw err; + } + } + console.error( + `dispatch: no free port in range ${START_PORT}-${MAX_PORT}. ` + + `Free one up or set PORT to an open port.`, + ); + throw lastError ?? new Error(`No free port in range ${START_PORT}-${MAX_PORT}`); +} + +// Only start the server when run as the entry point — importing this module +// (e.g. for `app`) must not bind a port. This preserves the prior +// default-export behavior where Bun served only the entry file. +if (import.meta.main) { + serveWithPortFallback(); +} -- cgit v1.2.3 From d01f17330f817cad995385df5b00d9ff8f201cdc Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 19:24:51 +0900 Subject: feat(frontend): add perm_lsp toggle to the permissions panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'LSP queries' to the ToolPermissions UI (disabled by default, matching the backend's perm_lsp gating). The existing generic settings route (/tabs/settings/perm_lsp) and agent-manager invalidation handle persistence and agent rebuild automatically — only the frontend store default and the permission entry needed wiring. --- packages/frontend/src/lib/components/ToolPermissions.svelte | 6 ++++++ packages/frontend/src/lib/settings.svelte.ts | 2 ++ 2 files changed, 8 insertions(+) (limited to 'packages') diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte index 0caf0fa..77452bf 100644 --- a/packages/frontend/src/lib/components/ToolPermissions.svelte +++ b/packages/frontend/src/lib/components/ToolPermissions.svelte @@ -47,6 +47,12 @@ const toolPermissions: ToolPermission[] = [ label: "YouTube transcripts", description: "Allow the AI to fetch YouTube video transcripts", }, + { + id: "lsp", + label: "LSP queries", + description: + "Allow the AI to query a language server for hover, go-to-definition, references, and more", + }, ]; const { diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 123008d..ff12d69 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -14,6 +14,7 @@ let toolPerms = $state>({ external_directory: false, web_search: false, youtube_transcribe: false, + lsp: false, }); let savedToolPerms = $state>({ read: true, @@ -26,6 +27,7 @@ let savedToolPerms = $state>({ external_directory: false, web_search: false, youtube_transcribe: false, + lsp: false, }); let skillChecks = $state>({}); let chunkLimit = $state(100); -- cgit v1.2.3