summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
commit062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch)
tree6097df0d53265f1a5e734aadab75c0334cb8e0e7
parentb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff)
downloaddispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.tar.gz
dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.zip
feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp)
Add Language Server Protocol integration modeled on opencode's, wired for this codebase's plain-TypeScript tool/agent architecture. Core (@dispatch/core): - lsp/client.ts: LSP/JSON-RPC client over stdio (vscode-jsonrpc) with the initialize handshake, didOpen/didChange sync, push + pull diagnostics (textDocument/diagnostic, workspace/diagnostic), and a generic request() passthrough for hover/definition/references/documentSymbol. - lsp/server.ts: resolves dispatch.toml [lsp] entries into spawn specs. Config-driven only — no builtin registry, no auto-download. - lsp/manager.ts: process-wide LspManager owning client lifecycles, keyed by root+serverID, lazy spawn + reuse + graceful shutdown. - lsp/language.ts: extension->languageId map incl. .luau -> "luau". - lsp/diagnostic.ts: error-only <diagnostics> block formatting (1-based). - tools/lsp.ts: on-demand 'lsp' tool (1-based coords -> 0-based wire). - write-file.ts: optional onAfterWrite hook for diagnostics-on-write. - config schema: validate [lsp] block; DispatchConfig.lsp + LspServerConfig. API (@dispatch/api): - AgentManager owns one LspManager; per-working-directory server cache cleared on config reload; diagnostics appended to write_file results; 'lsp' tool gated by new perm_lsp setting; shutdownAll on destroy(). Config: - dispatch.toml: documented, commented [lsp.luau-lsp] Roblox example. Tests: fake-lsp-server fixture + client/manager/server/diagnostic/schema/ tool/write-hook suites, plus an opt-in real-binary luau-lsp smoke test (auto-skipped when luau-lsp is absent). 652 pass; biome + 3 typechecks green.
-rw-r--r--biome.json1
-rw-r--r--bun.lock6
-rw-r--r--dispatch.toml42
-rw-r--r--packages/api/src/agent-manager.ts150
-rw-r--r--packages/api/tests/agent-manager.test.ts30
-rw-r--r--packages/api/tests/routes.test.ts30
-rw-r--r--packages/core/package.json2
-rw-r--r--packages/core/src/config/schema.ts107
-rw-r--r--packages/core/src/index.ts15
-rw-r--r--packages/core/src/lsp/client.ts658
-rw-r--r--packages/core/src/lsp/diagnostic.ts41
-rw-r--r--packages/core/src/lsp/index.ts18
-rw-r--r--packages/core/src/lsp/language.ts72
-rw-r--r--packages/core/src/lsp/manager.ts220
-rw-r--r--packages/core/src/lsp/server.ts68
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/write-file.ts30
-rw-r--r--packages/core/src/types/index.ts49
-rw-r--r--packages/core/tests/config/lsp-schema.test.ts110
-rw-r--r--packages/core/tests/fixture/lsp/fake-lsp-server.js195
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/write-file.test.ts46
27 files changed, 2565 insertions, 7 deletions
diff --git a/biome.json b/biome.json
index 408d2a0..af4f574 100644
--- a/biome.json
+++ b/biome.json
@@ -53,6 +53,7 @@
"!**/dist",
"!**/build",
"!references",
+ "!roblox-opencode-config-sample",
"!packaging",
"!**/release"
]
diff --git a/bun.lock b/bun.lock
index b6c5e6a..b92950e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -31,6 +31,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",
@@ -907,6 +909,10 @@
"vitest": ["[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
+ "vscode-jsonrpc": ["[email protected]", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
+
+ "vscode-languageserver-types": ["[email protected]", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
+
"web-tree-sitter": ["[email protected]", "", {}, "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
diff --git a/dispatch.toml b/dispatch.toml
index 43e164a..9f09ef7 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -49,3 +49,45 @@ read = "allow"
[permissions.external_directory]
"~/*" = "ask"
"/tmp/*" = "allow"
+
+# ─── Language Servers (LSP) ──────────────────────────────────────
+# Optional. Declare LSP servers to give agents diagnostics (and, with the
+# `lsp` tool, hover/definition/references) for the files they edit. This block
+# is PROJECT-SCOPED: it is read from the `dispatch.toml` in a tab's effective
+# working directory (re-consulted when you change the CWD). Config-driven only —
+# there is no builtin server registry and no auto-download, so the executable
+# in `command[0]` must already be on PATH.
+#
+# After `write_file` edits a file whose extension matches a server below,
+# dispatch opens it through the server and appends any error diagnostics to the
+# tool result ("LSP errors detected in this file, please fix: ..."). Grant the
+# `perm_lsp` permission to also expose the on-demand `lsp` tool.
+#
+# The example below is the Roblox Luau setup using luau-lsp
+# (https://github.com/JohnnyMorganz/luau-lsp). Uncomment and adapt for your
+# project. luau-lsp's `sourcemap.autogenerate` makes luau-lsp run
+# `rojo sourcemap --watch` itself, so `rojo` must be on PATH (or set
+# `[lsp.luau-lsp.env]` PATH / luau-lsp's sourcemap.rojoPath accordingly).
+#
+# [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"
+#
+# [lsp.luau-lsp.initialization.luau-lsp.sourcemap]
+# enabled = true
+# autogenerate = true
+# rojoProjectFile = "default.project.json"
+#
+# [lsp.luau-lsp.initialization.luau-lsp.types]
+# roblox = true
+# definitionFiles = ["globalTypes.d.luau"]
+# documentationFiles = ["api-docs.json"]
+#
+# [lsp.luau-lsp.initialization.luau-lsp.diagnostics]
+# strictDatamodelTypes = false
+#
+# [lsp.luau-lsp.initialization.luau-lsp.completion.imports]
+# useConst = true
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 2795a6c..7af2b67 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -14,6 +14,7 @@ import {
configToRuleset,
createConfigWatcher,
createListFilesTool,
+ createLspTool,
createReadFileSliceTool,
createReadFileTool,
createReadTabTool,
@@ -37,6 +38,7 @@ import {
getSetting,
getTab,
getUsageStatsForTab,
+ LspManager,
listOpenTabs,
loadAgent,
loadAgents,
@@ -45,9 +47,12 @@ import {
ModelRegistry,
type QueuedMessage,
type ReasoningEffort,
+ type ResolvedLspServer,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
+ reportDiagnostics,
resolveApiKey,
+ resolveServersFromConfig,
resolveTabPrefix,
type SkillDefinition,
type SystemChunkKind,
@@ -87,6 +92,7 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
"Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.",
read_tab:
"Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.",
+ lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.",
};
/**
@@ -99,6 +105,14 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
*/
const MAX_AGENT_AUTO_WAKES = 6;
+/**
+ * Cap on how many OTHER files' LSP error blocks are appended to a write_file
+ * result, after the written file's own errors. Bounds context spend when a
+ * single edit surfaces project-wide diagnostics. Mirrors opencode's
+ * MAX_PROJECT_DIAGNOSTICS_FILES.
+ */
+const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5;
+
const DEFAULT_SYSTEM_PROMPT =
"You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise.";
@@ -251,6 +265,21 @@ export class AgentManager {
private claudeAccounts: ClaudeAccount[] = [];
+ /**
+ * Process-wide owner of LSP client lifecycles. Servers are declared in the
+ * `dispatch.toml` of a tab's effective working directory; clients are
+ * spawned lazily per (root + server) and reused across tabs/turns. Shut
+ * down in `destroy()`.
+ */
+ private lspManager: LspManager = new LspManager();
+ /**
+ * Cache of resolved LSP servers per working directory, so we parse each
+ * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any
+ * config hot-reload (the watcher fires for the root config; directory-level
+ * configs are re-read on demand after a clear).
+ */
+ private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map();
+
constructor(permissionManager?: PermissionManager) {
this.permissionManager = permissionManager;
@@ -292,6 +321,10 @@ export class AgentManager {
}
// Update model registry with new config
this._initModelRegistry(newConfig);
+ // LSP server config may have changed — drop the per-directory cache
+ // so the next tool build re-reads each working directory's
+ // `dispatch.toml` `[lsp]` block.
+ this.lspServersByDir.clear();
// Re-discover Claude accounts: a config reload may accompany freshly
// imported credentials, and (critically) lets a process that failed
// account discovery at boot recover without a full restart.
@@ -334,6 +367,77 @@ export class AgentManager {
}
}
+ /**
+ * Resolve (and cache) the LSP servers configured for a working directory.
+ *
+ * LSP config is project-scoped: it lives in the `dispatch.toml` of the
+ * tab's effective working directory, NOT the global config. We read that
+ * directory's config once and cache the resolved servers; the cache is
+ * cleared on config hot-reload. Returns `[]` when the directory has no
+ * `[lsp]` block (the common case).
+ */
+ private getLspServersForDir(dir: string): ResolvedLspServer[] {
+ const cached = this.lspServersByDir.get(dir);
+ if (cached) return cached;
+ let servers: ResolvedLspServer[] = [];
+ try {
+ const dirConfig = loadConfig(dir);
+ servers = resolveServersFromConfig(dirConfig.lsp);
+ } catch (err) {
+ console.warn(
+ `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ servers = [];
+ }
+ this.lspServersByDir.set(dir, servers);
+ return servers;
+ }
+
+ /**
+ * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's
+ * working directory has LSP servers configured. The hook touches the
+ * just-written file through the LSP and returns a formatted diagnostics
+ * block (the written file's errors first, then a small cap of other-file
+ * errors) — opencode's diagnostics-on-write pattern. Returns `undefined`
+ * when no server matches, so writes stay zero-overhead for non-LSP files.
+ */
+ private buildAfterWriteHook(
+ workingDirectory: string,
+ servers: ResolvedLspServer[],
+ ): ((absolutePath: string) => Promise<string>) | undefined {
+ if (servers.length === 0) return undefined;
+ const manager = this.lspManager;
+ return async (absolutePath: string): Promise<string> => {
+ if (!manager.hasServerForFile(absolutePath, servers)) return "";
+ await manager.touchFile({
+ file: absolutePath,
+ root: workingDirectory,
+ servers,
+ mode: "document",
+ });
+ const diagnostics = manager.getDiagnostics({
+ root: workingDirectory,
+ servers,
+ file: absolutePath,
+ });
+ let output = "";
+ let otherFileCount = 0;
+ for (const [file, issues] of Object.entries(diagnostics)) {
+ const current = file === absolutePath;
+ if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue;
+ const block = reportDiagnostics(file, issues);
+ if (!block) continue;
+ if (current) {
+ output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`;
+ } else {
+ otherFileCount++;
+ output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`;
+ }
+ }
+ return output;
+ };
+ }
+
private _initModelRegistry(config: DispatchConfig): void {
if (config.keys) {
if (this.modelRegistry) {
@@ -408,8 +512,9 @@ export class AgentManager {
const permReadTab = getSetting("perm_read_tab") === "allow";
const permWebSearch = getSetting("perm_web_search") === "allow";
const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
+ const permLsp = getSetting("perm_lsp") === "allow";
const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`;
+ const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permLsp}:${sysPrompt}`;
// If the override differs or permissions changed, invalidate the cached agent
if (
@@ -451,6 +556,12 @@ export class AgentManager {
// Ignore — tool execution will surface the error naturally
}
+ // Resolve LSP servers for this working directory once (cached).
+ // Drives both diagnostics-on-write (the write_file hook) and the
+ // optional `lsp` tool. Empty for directories with no `[lsp]` block.
+ const lspServers = this.getLspServersForDir(workingDirectory);
+ const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers);
+
// Build tools list — child agents use their toolsOverride whitelist,
// parent agents use permission settings from DB
const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = [];
@@ -475,7 +586,10 @@ export class AgentManager {
toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
}
if (allowed.has("write_file")) {
- toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) });
+ toolEntries.push({
+ name: "write_file",
+ tool: createWriteFileTool(workingDirectory, afterWriteHook),
+ });
}
if (allowed.has("run_shell")) {
toolEntries.push({
@@ -486,6 +600,16 @@ export class AgentManager {
if (allowed.has("web_search")) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
+ if (allowed.has("lsp") && lspServers.length > 0) {
+ toolEntries.push({
+ name: "lsp",
+ tool: createLspTool(() => ({
+ manager: this.lspManager,
+ workingDirectory,
+ servers: lspServers,
+ })),
+ });
+ }
if (allowed.has("youtube_transcribe")) {
toolEntries.push({
name: "youtube_transcribe",
@@ -561,7 +685,10 @@ export class AgentManager {
toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
}
if (permEdit) {
- toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) });
+ toolEntries.push({
+ name: "write_file",
+ tool: createWriteFileTool(workingDirectory, afterWriteHook),
+ });
}
if (permBash) {
toolEntries.push({
@@ -572,6 +699,19 @@ export class AgentManager {
if (permWebSearch) {
toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
}
+ // The `lsp` tool exposes diagnostics + navigation on demand. It is
+ // gated by `perm_lsp` AND requires at least one server configured
+ // in the working directory's `dispatch.toml`.
+ if (permLsp && lspServers.length > 0) {
+ toolEntries.push({
+ name: "lsp",
+ tool: createLspTool(() => ({
+ manager: this.lspManager,
+ workingDirectory,
+ servers: lspServers,
+ })),
+ });
+ }
if (permYoutubeTranscribe) {
toolEntries.push({
name: "youtube_transcribe",
@@ -1858,5 +1998,9 @@ export class AgentManager {
destroy(): void {
this.configWatcher?.close();
this.skillsWatcher?.close();
+ // Shut down all long-lived LSP server processes. Fire-and-forget: the
+ // promise is detached so `destroy()` stays synchronous (matching its
+ // existing contract), but every client gets `shutdown()` called.
+ void this.lspManager.shutdownAll();
}
}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 3353aff..6efe15e 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -228,6 +228,36 @@ vi.mock("@dispatch/core", () => ({
execute: async () => ["file1.ts"],
};
},
+ createLspTool(_getContext: unknown): ToolDefinition {
+ return {
+ name: "lsp",
+ description: "query the language server",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock lsp",
+ };
+ },
+ LspManager: class MockLspManager {
+ hasServerForFile() {
+ return false;
+ }
+ async getClients() {
+ return [];
+ }
+ async touchFile() {}
+ getDiagnostics() {
+ return {};
+ }
+ async request() {
+ return [];
+ }
+ async shutdownAll() {}
+ },
+ resolveServersFromConfig(_lsp: unknown) {
+ return [];
+ },
+ reportDiagnostics(_file: string, _issues: unknown) {
+ return "";
+ },
createRunShellTool(_wd: string): ToolDefinition {
return {
name: "run_shell",
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index a8db5ce..6ec8ca6 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -82,6 +82,36 @@ vi.mock("@dispatch/core", () => ({
execute: async () => ["file1.ts"],
};
},
+ createLspTool(_getContext: unknown): ToolDefinition {
+ return {
+ name: "lsp",
+ description: "query the language server",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock lsp",
+ };
+ },
+ LspManager: class MockLspManager {
+ hasServerForFile() {
+ return false;
+ }
+ async getClients() {
+ return [];
+ }
+ async touchFile() {}
+ getDiagnostics() {
+ return {};
+ }
+ async request() {
+ return [];
+ }
+ async shutdownAll() {}
+ },
+ resolveServersFromConfig(_lsp: unknown) {
+ return [];
+ },
+ reportDiagnostics(_file: string, _issues: unknown) {
+ return "";
+ },
createRunShellTool(_wd: string): ToolDefinition {
return {
name: "run_shell",
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<string, unknown> {
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<string, unknown> }
+ : {}),
+ ...(disabled ? { disabled: true } : {}),
+ };
+ return server;
+}
+
+function validateLsp(
+ raw: unknown,
+ path: string,
+ errors: ConfigError[],
+): Record<string, LspServerConfig> | undefined {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return undefined;
+ }
+ const result: Record<string, LspServerConfig> = {};
+ 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<string, LspServerConfig> | 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<string, unknown>;
+}
+
+interface ServerCapabilities {
+ textDocumentSync?: number | { change?: number };
+ diagnosticProvider?: unknown;
+ [key: string]: unknown;
+}
+
+interface DiagnosticRequestResult {
+ handled: boolean;
+ matched: boolean;
+ byFile: Map<string, Diagnostic[]>;
+}
+
+interface CapabilityRegistration {
+ id: string;
+ method: string;
+ registerOptions?: {
+ identifier?: string;
+ workspaceDiagnostics?: boolean;
+ };
+}
+
+type DocumentDiagnosticReport = {
+ items?: Diagnostic[];
+ relatedDocuments?: Record<string, DocumentDiagnosticReport>;
+};
+
+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<number>;
+ /** Snapshot of all known diagnostics keyed by absolute file path. */
+ readonly diagnostics: Map<string, Diagnostic[]>;
+ /** Wait until diagnostics for `path` settle (push and/or pull). */
+ waitForDiagnostics(request: {
+ path: string;
+ version: number;
+ mode?: "document" | "full";
+ after?: number;
+ }): Promise<void>;
+ /** Generic LSP request passthrough (hover, definition, references, …). */
+ request<T = unknown>(method: string, params: unknown): Promise<T | null>;
+ /** Shut the connection and child process down. */
+ shutdown(): Promise<void>;
+}
+
+function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
+ return new Promise<T>((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<string>();
+ 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<unknown>((acc, key) => {
+ if (!acc || typeof acc !== "object" || !(key in acc)) return undefined;
+ return (acc as Record<string, unknown>)[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<LspClient> {
+ 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<string, Diagnostic[]>();
+ const pullDiagnostics = new Map<string, Diagnostic[]>();
+ const published = new Map<string, { at: number; version?: number }>();
+ const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
+ const registrationListeners = new Set<() => void>();
+ const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
+ const files: Record<string, { version: number; text: string }> = {};
+
+ 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<string, Diagnostic[]>();
+ 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<DiagnosticRequestResult> {
+ const report = await withTimeout(
+ connection.sendRequest<DocumentDiagnosticReport | null>("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<string, Diagnostic[]>();
+ 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<DiagnosticRequestResult> {
+ const report = await withTimeout(
+ connection.sendRequest<WorkspaceDiagnosticReport | null>("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<string, Diagnostic[]>();
+ 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<DiagnosticRequestResult>[],
+ 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<boolean>((resolvePromise) => {
+ let finished = false;
+ let timer: ReturnType<typeof setTimeout> | 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<boolean>((resolvePromise) => {
+ let finished = false;
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
+ let timeoutTimer: ReturnType<typeof setTimeout> | 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<string, Diagnostic[]>();
+ 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<T = unknown>(method: string, params: unknown): Promise<T | null> {
+ return connection.sendRequest<T>(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<number, string> = {
+ 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 `<diagnostics file="…">` 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 `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`;
+}
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.<id>]`
+// 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<string, string> = {
+ // 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<string, LspClient>();
+ private spawning = new Map<string, Promise<LspClient | undefined>>();
+ private broken = new Set<string>();
+
+ 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<LspClient[]> {
+ 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<LspClient | undefined> {
+ let handle: ReturnType<ResolvedLspServer["spawn"]>;
+ 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<void> {
+ 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<string, Diagnostic[]> {
+ const results: Record<string, Diagnostic[]> = {};
+ 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<string, unknown>;
+ }): Promise<unknown[]> {
+ 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<void> {
+ 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.<id>]` 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<string, string> | undefined,
+ initialization: Record<string, unknown> | 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<string, LspServerConfig> | 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<string, unknown>): Promise<string> => {
+ 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<string, unknown> = {
+ 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<string>;
+
+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<string, string | Record<string, string>>;
+ /**
+ * 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<string, LspServerConfig>;
+}
+
+/**
+ * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]`
+ * 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<string, string>;
+ /**
+ * `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<string, unknown>;
+ /** 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<Diagnostic> & { 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 <diagnostics file> block", () => {
+ const out = report("src/a.luau", [diag({ message: "boom" })]);
+ expect(out).toContain('<diagnostics file="src/a.luau">');
+ expect(out).toContain("ERROR [1:1] boom");
+ expect(out).toContain("</diagnostics>");
+ });
+
+ 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> = {}): 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"));
+ });
+ });
});