summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
committerAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
commite7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch)
tree447095fd60b43980358d1565506f3ae2430e5f29 /packages/lsp
parent35937cee7f838e414eb8147c67205e01d85a4da0 (diff)
downloaddispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz
dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block — the first bytes of the prompt-cache prefix — diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs §3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/lsp')
-rw-r--r--packages/lsp/package.json11
-rw-r--r--packages/lsp/src/client.test.ts291
-rw-r--r--packages/lsp/src/client.ts366
-rw-r--r--packages/lsp/src/config.test.ts119
-rw-r--r--packages/lsp/src/config.ts148
-rw-r--r--packages/lsp/src/diagnostics.test.ts51
-rw-r--r--packages/lsp/src/diagnostics.ts86
-rw-r--r--packages/lsp/src/extension.ts120
-rw-r--r--packages/lsp/src/framing.test.ts46
-rw-r--r--packages/lsp/src/framing.ts67
-rw-r--r--packages/lsp/src/index.ts29
-rw-r--r--packages/lsp/src/language.test.ts28
-rw-r--r--packages/lsp/src/language.ts36
-rw-r--r--packages/lsp/src/manager.test.ts204
-rw-r--r--packages/lsp/src/manager.ts215
-rw-r--r--packages/lsp/src/root.test.ts51
-rw-r--r--packages/lsp/src/root.ts44
-rw-r--r--packages/lsp/src/rpc.test.ts78
-rw-r--r--packages/lsp/src/rpc.ts132
-rw-r--r--packages/lsp/src/tool.test.ts179
-rw-r--r--packages/lsp/src/tool.ts225
-rw-r--r--packages/lsp/src/types.ts23
-rw-r--r--packages/lsp/src/watched-files.test.ts81
-rw-r--r--packages/lsp/src/watched-files.ts110
-rw-r--r--packages/lsp/tsconfig.json6
25 files changed, 2746 insertions, 0 deletions
diff --git a/packages/lsp/package.json b/packages/lsp/package.json
new file mode 100644
index 0000000..2b5a360
--- /dev/null
+++ b/packages/lsp/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/lsp",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/lsp/src/client.test.ts b/packages/lsp/src/client.test.ts
new file mode 100644
index 0000000..681860f
--- /dev/null
+++ b/packages/lsp/src/client.test.ts
@@ -0,0 +1,291 @@
+import { describe, expect, it } from "vitest";
+import {
+ type FileWatcher,
+ type FsAccess,
+ LanguageServerClient,
+ type SpawnProcess,
+} from "./client.js";
+import { encode } from "./framing.js";
+
+function makeClient(overrides?: {
+ readonly spawn?: SpawnProcess;
+ readonly fileWatcher?: FileWatcher;
+ readonly fs?: FsAccess;
+ readonly initialization?: Record<string, unknown>;
+}): {
+ client: LanguageServerClient;
+ stdinChunks: Uint8Array[];
+ serverResponses: (msg: string) => void;
+} {
+ const stdinChunks: Uint8Array[] = [];
+ let serverMessageHandler: ((msg: string) => void) | null = null;
+
+ const mockSpawn: SpawnProcess = () => ({
+ stdin: { write: (bytes) => stdinChunks.push(bytes) },
+ stdout: {
+ on: (_event: string, cb: (data: Uint8Array) => void) => {
+ // We'll feed messages through serverResponses
+ serverMessageHandler = (msg: string) => {
+ cb(encode(msg));
+ };
+ },
+ },
+ pid: 123,
+ kill: () => {},
+ });
+
+ const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({
+ close: () => {},
+ });
+
+ const mockFs: FsAccess = {
+ readText: async (path) => `// content of ${path}`,
+ exists: async () => true,
+ };
+
+ const client = new LanguageServerClient({
+ spawn: overrides?.spawn ?? mockSpawn,
+ fileWatcher: overrides?.fileWatcher ?? mockFileWatcher,
+ fs: overrides?.fs ?? mockFs,
+ command: ["test-lsp"],
+ root: "/project",
+ serverId: "test",
+ ...(overrides?.initialization ? { initialization: overrides.initialization } : {}),
+ });
+
+ return {
+ client,
+ stdinChunks,
+ serverResponses: (msg: string) => serverMessageHandler?.(msg),
+ };
+}
+
+describe("client", () => {
+ it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => {
+ const { client, stdinChunks, serverResponses } = makeClient();
+
+ const startPromise = client.start();
+
+ // Wait for the initialize message to be sent
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Parse the sent messages to find initialize
+ const sentMessages = stdinChunks.map((chunk) => {
+ const decoded = new TextDecoder().decode(chunk);
+ const headerEnd = decoded.indexOf("\r\n\r\n");
+ return JSON.parse(decoded.slice(headerEnd + 4));
+ });
+
+ const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize");
+ expect(initMsg).toBeDefined();
+ expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe(
+ true,
+ );
+
+ // Send initialize response
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: initMsg.id,
+ result: { capabilities: {} },
+ }),
+ );
+
+ await startPromise;
+ expect(client.getState()).toBe("connected");
+ });
+
+ it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => {
+ const { client, serverResponses } = makeClient();
+ const startPromise = client.start();
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: { capabilities: {} },
+ }),
+ );
+
+ await startPromise;
+
+ // Register workspace/didChangeWatchedFiles
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 100,
+ method: "client/registerCapability",
+ params: {
+ registrations: [
+ {
+ id: "reg-watched",
+ method: "workspace/didChangeWatchedFiles",
+ registerOptions: {
+ watchers: [{ globPattern: "**/*.luau" }],
+ },
+ },
+ {
+ id: "reg-diag",
+ method: "textDocument/diagnostic",
+ registerOptions: {},
+ },
+ ],
+ },
+ }),
+ );
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ const registry = client.getWatchedFilesRegistry();
+ expect(registry.matches("src/main.luau")).toBe(true);
+ expect(registry.matches("src/main.ts")).toBe(false);
+ });
+
+ it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => {
+ const callbackHolder: {
+ cb:
+ | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void)
+ | null;
+ } = { cb: null };
+
+ const trackingFileWatcher: FileWatcher = (_root, onEvent) => {
+ callbackHolder.cb = onEvent;
+ return { close: () => {} };
+ };
+
+ const { client, stdinChunks, serverResponses } = makeClient({
+ fileWatcher: trackingFileWatcher,
+ });
+
+ const startPromise = client.start();
+ await new Promise((r) => setTimeout(r, 50));
+
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: { capabilities: {} },
+ }),
+ );
+
+ await startPromise;
+
+ // Register a watcher for sourcemap.json
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 100,
+ method: "client/registerCapability",
+ params: {
+ registrations: [
+ {
+ id: "reg-1",
+ method: "workspace/didChangeWatchedFiles",
+ registerOptions: {
+ watchers: [{ globPattern: "sourcemap.json" }],
+ },
+ },
+ ],
+ },
+ }),
+ );
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ // Simulate a file change
+ const onFsEvent = callbackHolder.cb;
+ if (!onFsEvent) throw new Error("file watcher callback was never registered");
+ onFsEvent({ type: "change", path: "/project/sourcemap.json" });
+
+ await new Promise((r) => setTimeout(r, 100));
+
+ // Check that the notification was sent
+ const sentMessages = stdinChunks.map((chunk) => {
+ const decoded = new TextDecoder().decode(chunk);
+ const headerEnd = decoded.indexOf("\r\n\r\n");
+ return JSON.parse(decoded.slice(headerEnd + 4));
+ });
+
+ const didChangeMsg = sentMessages.find(
+ (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles",
+ );
+ expect(didChangeMsg).toBeDefined();
+ expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json");
+ expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed
+ });
+
+ it("publishDiagnostics are stored + returned", async () => {
+ const { client, serverResponses } = makeClient();
+ const startPromise = client.start();
+ await new Promise((r) => setTimeout(r, 50));
+
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ result: { capabilities: {} },
+ }),
+ );
+
+ await startPromise;
+
+ // Send diagnostics
+ serverResponses(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ method: "textDocument/publishDiagnostics",
+ params: {
+ uri: "file:///project/test.ts",
+ diagnostics: [
+ {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ severity: 1,
+ message: "Test error",
+ },
+ ],
+ },
+ }),
+ );
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ const store = client.getDiagnosticsStore();
+ const formatted = store.format("file:///project/test.ts");
+ expect(formatted).toContain("ERROR");
+ expect(formatted).toContain("Test error");
+ });
+
+ it("shutdown kills the process", async () => {
+ const state = { killed: false };
+ const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null };
+ const killableSpawn: SpawnProcess = () => ({
+ stdin: { write: () => {} },
+ stdout: {
+ on: (_event: string, cb: (data: Uint8Array) => void) => {
+ stdoutHolder.cb = cb;
+ },
+ },
+ pid: 123,
+ kill: () => {
+ state.killed = true;
+ },
+ });
+
+ const { client } = makeClient({ spawn: killableSpawn });
+ const startPromise = client.start();
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Deliver the initialize response through the spawned process's own
+ // stdout (the real read path), so start() can complete the handshake.
+ stdoutHolder.cb?.(
+ encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })),
+ );
+
+ await startPromise;
+
+ client.shutdown();
+ expect(state.killed).toBe(true);
+ });
+});
diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts
new file mode 100644
index 0000000..114b8eb
--- /dev/null
+++ b/packages/lsp/src/client.ts
@@ -0,0 +1,366 @@
+/**
+ * Language-server client — wires codec + rpc + edges; runs the
+ * initialize handshake, honors server→client requests, runs the
+ * FileWatcher, and forwards matching disk changes.
+ */
+
+import { DiagnosticsStore, type PublishDiagnosticsParams } from "./diagnostics.js";
+import { FrameDecoder } from "./framing.js";
+import { JsonRpcConnection, type WriteFn } from "./rpc.js";
+import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js";
+
+export interface SpawnedProcess {
+ readonly stdin: { readonly write: (bytes: Uint8Array) => void };
+ readonly stdout:
+ | AsyncIterable<Uint8Array>
+ | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void };
+ readonly stderr?:
+ | AsyncIterable<Uint8Array>
+ | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }
+ | undefined;
+ readonly pid: number | undefined;
+ readonly kill: () => void;
+}
+
+export type SpawnProcess = (
+ command: string[],
+ opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined },
+) => SpawnedProcess;
+
+export interface FileWatcherHandle {
+ readonly close: () => void;
+}
+
+export type FileWatcher = (
+ root: string,
+ onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void,
+) => FileWatcherHandle;
+
+export interface FsAccess {
+ readonly readText: (path: string) => Promise<string>;
+ readonly exists: (path: string) => Promise<boolean>;
+}
+
+export interface ClientCapabilities {
+ readonly window: { readonly workDoneProgress: boolean };
+ readonly workspace: {
+ readonly configuration: boolean;
+ readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean };
+ readonly diagnostics: { readonly refreshSupport: boolean };
+ };
+ readonly textDocument: {
+ readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean };
+ readonly diagnostic: {
+ readonly dynamicRegistration: boolean;
+ readonly relatedDocumentSupport: boolean;
+ };
+ readonly publishDiagnostics: { readonly versionSupport: boolean };
+ };
+}
+
+export const CLIENT_CAPABILITIES: ClientCapabilities = {
+ 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 },
+ },
+};
+
+export interface ClientDeps {
+ readonly spawn: SpawnProcess;
+ readonly fileWatcher: FileWatcher;
+ readonly fs: FsAccess;
+ readonly command: readonly string[];
+ readonly env?: Readonly<Record<string, string>> | undefined;
+ readonly root: string;
+ readonly initialization?: Readonly<Record<string, unknown>> | undefined;
+ readonly serverId: string;
+}
+
+export type ClientState = "starting" | "connected" | "error" | "not-started";
+
+export class LanguageServerClient {
+ readonly serverId: string;
+ readonly root: string;
+ private process: SpawnedProcess | null = null;
+ private rpc: JsonRpcConnection | null = null;
+ private decoder = new FrameDecoder();
+ private diagnostics = new DiagnosticsStore();
+ private watchedFiles = new WatchedFilesRegistry();
+ private fileWatcherHandle: FileWatcherHandle | null = null;
+ private state: ClientState = "not-started";
+ private stateError: string | undefined;
+ private deps: ClientDeps;
+ private openDocuments = new Map<string, number>();
+
+ constructor(deps: ClientDeps) {
+ this.deps = deps;
+ this.serverId = deps.serverId;
+ this.root = deps.root;
+ }
+
+ getState(): ClientState {
+ return this.state;
+ }
+
+ getStateError(): string | undefined {
+ return this.stateError;
+ }
+
+ async start(): Promise<void> {
+ this.state = "starting";
+ try {
+ const spawnOpts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> } = {
+ cwd: this.root,
+ };
+ if (this.deps.env) {
+ (spawnOpts as { env?: Readonly<Record<string, string>> }).env = this.deps.env;
+ }
+ const proc = this.deps.spawn(this.deps.command as string[], spawnOpts);
+ this.process = proc;
+
+ const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes);
+ const rpc = new JsonRpcConnection(writeFn);
+ this.rpc = rpc;
+
+ this.setupServerHandlers(rpc);
+
+ const stdoutSource = proc.stdout;
+ if (Symbol.asyncIterator in stdoutSource) {
+ this.readFromAsyncIterable(stdoutSource as AsyncIterable<Uint8Array>);
+ } else {
+ this.readFromEventSource(
+ stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void },
+ );
+ }
+
+ await this.initialize(rpc);
+ this.state = "connected";
+ } catch (err: unknown) {
+ this.state = "error";
+ this.stateError = err instanceof Error ? err.message : String(err);
+ }
+ }
+
+ private readFromAsyncIterable(source: AsyncIterable<Uint8Array>): void {
+ (async () => {
+ try {
+ for await (const chunk of source) {
+ this.handleBytes(chunk);
+ }
+ } catch {
+ // process exited
+ }
+ })();
+ }
+
+ private readFromEventSource(source: {
+ readonly on: (event: string, cb: (data: Uint8Array) => void) => void;
+ }): void {
+ source.on("data", (data: Uint8Array) => {
+ this.handleBytes(data);
+ });
+ }
+
+ private handleBytes(chunk: Uint8Array): void {
+ const messages = this.decoder.decode(chunk);
+ for (const msg of messages) {
+ this.rpc?.handleMessage(msg);
+ }
+ }
+
+ private setupServerHandlers(rpc: JsonRpcConnection): void {
+ rpc.onNotification("textDocument/publishDiagnostics", (params) => {
+ this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams);
+ });
+
+ rpc.onRequest("workspace/configuration", (params) => {
+ const { items } = params as { readonly items: readonly { readonly section?: string }[] };
+ const init = this.deps.initialization ?? {};
+ return items.map((item) => {
+ if (item.section) {
+ const keys = item.section.split(".");
+ let value: unknown = init;
+ for (const key of keys) {
+ if (value && typeof value === "object" && key in value) {
+ value = (value as Record<string, unknown>)[key];
+ } else {
+ return undefined;
+ }
+ }
+ return value;
+ }
+ return init;
+ });
+ });
+
+ rpc.onRequest("workspace/workspaceFolders", () => {
+ return [{ uri: `file://${this.root}`, name: this.root }];
+ });
+
+ rpc.onRequest("window/workDoneProgress/create", () => null);
+ rpc.onRequest("workspace/diagnostic/refresh", () => null);
+
+ rpc.onRequest("client/registerCapability", (params) => {
+ const { registrations } = params as {
+ readonly registrations: readonly {
+ readonly id: string;
+ readonly method: string;
+ readonly registerOptions?: unknown;
+ }[];
+ };
+ for (const reg of registrations) {
+ if (reg.method === "textDocument/diagnostic") {
+ // Store diagnostic registration (future use)
+ } else if (reg.method === "workspace/didChangeWatchedFiles") {
+ const opts = reg.registerOptions as
+ | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions
+ | undefined;
+ if (opts) {
+ this.watchedFiles.applyRegister({
+ id: reg.id,
+ method: reg.method,
+ registerOptions: opts,
+ });
+ }
+ }
+ }
+ return null;
+ });
+
+ rpc.onRequest("client/unregisterCapability", (params) => {
+ const { unregistrations } = params as {
+ readonly unregistrations: readonly {
+ readonly id: string;
+ readonly method: string;
+ }[];
+ };
+ for (const unreg of unregistrations) {
+ this.watchedFiles.applyUnregister(unreg);
+ }
+ return null;
+ });
+ }
+
+ private async initialize(rpc: JsonRpcConnection): Promise<void> {
+ const timeout = 45_000;
+
+ const initPromise = rpc.sendRequest("initialize", {
+ processId: this.process?.pid ?? null,
+ rootUri: `file://${this.root}`,
+ workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }],
+ capabilities: CLIENT_CAPABILITIES,
+ });
+
+ const timeoutPromise = new Promise<never>((_, reject) => {
+ setTimeout(() => reject(new Error("Initialize timeout")), timeout);
+ });
+
+ await Promise.race([initPromise, timeoutPromise]);
+
+ rpc.sendNotification("initialized", {});
+
+ if (this.deps.initialization) {
+ rpc.sendNotification("workspace/didChangeConfiguration", {
+ settings: this.deps.initialization,
+ });
+ }
+
+ this.startFileWatcher();
+ }
+
+ private startFileWatcher(): void {
+ const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`;
+ this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => {
+ const changeType =
+ event.type === "create"
+ ? FileChangeType.Created
+ : event.type === "delete"
+ ? FileChangeType.Deleted
+ : FileChangeType.Changed;
+
+ const relativePath = event.path.startsWith(rootPrefix)
+ ? event.path.slice(rootPrefix.length)
+ : event.path.replace(/^\/+/, "");
+
+ if (this.watchedFiles.matches(relativePath)) {
+ this.rpc?.sendNotification("workspace/didChangeWatchedFiles", {
+ changes: [{ uri: `file://${event.path}`, type: changeType }],
+ });
+ }
+ });
+ }
+
+ async open(filePath: string): Promise<void> {
+ const rpc = this.rpc;
+ if (!rpc || this.state !== "connected") return;
+
+ const version = (this.openDocuments.get(filePath) ?? 0) + 1;
+ this.openDocuments.set(filePath, version);
+
+ try {
+ const text = await this.deps.fs.readText(filePath);
+ rpc.sendNotification("textDocument/didOpen", {
+ textDocument: {
+ uri: `file://${filePath}`,
+ languageId: "unknown",
+ version,
+ text,
+ },
+ });
+ } catch {
+ // file may not exist
+ }
+ }
+
+ async waitForDiagnostics(filePath: string, timeoutMs = 10_000): Promise<string> {
+ await this.open(filePath);
+ return new Promise<string>((resolve) => {
+ const start = Date.now();
+ const check = () => {
+ const formatted = this.diagnostics.format(`file://${filePath}`);
+ if (formatted) {
+ resolve(formatted);
+ return;
+ }
+ if (Date.now() - start >= timeoutMs) {
+ resolve(this.diagnostics.format(`file://${filePath}`) || "");
+ return;
+ }
+ setTimeout(check, 100);
+ };
+ check();
+ });
+ }
+
+ getWatchedFilesRegistry(): WatchedFilesRegistry {
+ return this.watchedFiles;
+ }
+
+ getDiagnosticsStore(): DiagnosticsStore {
+ return this.diagnostics;
+ }
+
+ async request(method: string, params?: unknown): Promise<unknown> {
+ if (!this.rpc || this.state !== "connected") {
+ throw new Error("Client not connected");
+ }
+ return this.rpc.sendRequest(method, params);
+ }
+
+ shutdown(): void {
+ this.fileWatcherHandle?.close();
+ this.fileWatcherHandle = null;
+ this.process?.kill();
+ this.process = null;
+ this.rpc?.dispose();
+ this.rpc = null;
+ this.state = "not-started";
+ }
+}
diff --git a/packages/lsp/src/config.test.ts b/packages/lsp/src/config.test.ts
new file mode 100644
index 0000000..1462490
--- /dev/null
+++ b/packages/lsp/src/config.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+import { resolveServers } from "./config.js";
+
+describe("config", () => {
+ it("built-in typescript resolves when tsconfig.json exists", async () => {
+ const servers = await resolveServers({
+ cwd: "/project",
+ dispatchLspJson: null,
+ opencodeJson: null,
+ exists: async (path) => path === "/project/tsconfig.json",
+ });
+
+ const ts = servers.find((s) => s.id === "typescript");
+ expect(ts).toBeDefined();
+ expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]);
+ expect(ts?.extensions).toContain(".ts");
+ expect(ts?.rootMarkers).toContain("tsconfig.json");
+ });
+
+ it(".dispatch/lsp.json servers resolve", async () => {
+ const config = JSON.stringify({
+ servers: {
+ mylsp: {
+ command: ["my-lsp", "--stdio"],
+ extensions: [".ml"],
+ rootMarkers: ["Makefile"],
+ },
+ },
+ });
+
+ const servers = await resolveServers({
+ cwd: "/project",
+ dispatchLspJson: config,
+ opencodeJson: null,
+ exists: async () => false,
+ });
+
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("mylsp");
+ expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]);
+ });
+
+ it("opencode.json lsp is used only as fallback", async () => {
+ const opencodeConfig = JSON.stringify({
+ lsp: {
+ fallback: {
+ command: ["fallback-lsp"],
+ extensions: [".fb"],
+ },
+ },
+ });
+
+ const servers = await resolveServers({
+ cwd: "/project",
+ dispatchLspJson: null,
+ opencodeJson: opencodeConfig,
+ exists: async () => false,
+ });
+
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("fallback");
+ });
+
+ it(".dispatch/lsp.json wins over opencode.json", async () => {
+ const dispatchConfig = JSON.stringify({
+ servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } },
+ });
+ const opencodeConfig = JSON.stringify({
+ lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } },
+ });
+
+ const servers = await resolveServers({
+ cwd: "/project",
+ dispatchLspJson: dispatchConfig,
+ opencodeJson: opencodeConfig,
+ exists: async () => false,
+ });
+
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("primary");
+ });
+
+ it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => {
+ const config = JSON.stringify({
+ servers: {
+ luau: {
+ command: ["luau-lsp", "lsp"],
+ extensions: [".luau"],
+ initialization: {
+ "luau-lsp": {
+ sourcemap: {
+ autogenerate: true,
+ rojoProjectFile: "default.project.json",
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const servers = await resolveServers({
+ cwd: "/project",
+ dispatchLspJson: config,
+ opencodeJson: null,
+ exists: async () => false,
+ });
+
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.sidecar).toBeDefined();
+ expect(servers[0]?.sidecar?.command).toEqual([
+ "rojo",
+ "sourcemap",
+ "default.project.json",
+ "--watch",
+ "-o",
+ "sourcemap.json",
+ ]);
+ });
+});
diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts
new file mode 100644
index 0000000..f0c1f3f
--- /dev/null
+++ b/packages/lsp/src/config.ts
@@ -0,0 +1,148 @@
+/**
+ * PURE config resolution — resolve language server configurations.
+ *
+ * Sources, in precedence order:
+ * 1. cwd/.dispatch/lsp.json servers
+ * 2. fallback cwd/opencode.json lsp
+ * 3. the built-in registry
+ *
+ * Sidecar auto-detect: if a server's initialization has luau-lsp sourcemap
+ * with autogenerate=true and a rojoProjectFile, attach a rojo sidecar.
+ */
+
+export interface ResolvedServer {
+ readonly id: string;
+ readonly name: string;
+ readonly command: readonly string[];
+ readonly env?: Readonly<Record<string, string>> | undefined;
+ readonly extensions: readonly string[];
+ readonly rootMarkers: readonly string[];
+ readonly initialization?: Readonly<Record<string, unknown>> | undefined;
+ readonly sidecar?: { readonly command: readonly string[] } | undefined;
+}
+
+export interface ServerConfig {
+ readonly id?: string | undefined;
+ readonly name?: string | undefined;
+ readonly command: readonly string[];
+ readonly env?: Readonly<Record<string, string>> | undefined;
+ readonly extensions?: readonly string[] | undefined;
+ readonly rootMarkers?: readonly string[] | undefined;
+ readonly initialization?: Readonly<Record<string, unknown>> | undefined;
+ readonly watch?: readonly string[] | undefined;
+}
+
+export interface LspJsonConfig {
+ readonly servers?: Readonly<Record<string, ServerConfig>> | undefined;
+}
+
+export interface OpencodeJsonConfig {
+ readonly lsp?: Readonly<Record<string, ServerConfig>> | undefined;
+}
+
+export interface ResolveServersDeps {
+ readonly cwd: string;
+ readonly dispatchLspJson: string | null;
+ readonly opencodeJson: string | null;
+ readonly exists: (path: string) => Promise<boolean>;
+}
+
+const BUILT_IN_REGISTRY: Record<string, ResolvedServer> = {
+ typescript: {
+ id: "typescript",
+ name: "TypeScript Language Server",
+ command: ["typescript-language-server", "--stdio"],
+ extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
+ rootMarkers: ["tsconfig.json", "package.json"],
+ },
+};
+
+export async function resolveServers(deps: ResolveServersDeps): Promise<ResolvedServer[]> {
+ const result = new Map<string, ResolvedServer>();
+
+ if (deps.dispatchLspJson) {
+ try {
+ const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig;
+ if (config.servers) {
+ for (const [key, server] of Object.entries(config.servers)) {
+ const resolved = resolveServer(key, server);
+ result.set(resolved.id, resolved);
+ }
+ }
+ } catch {
+ // ignore parse errors
+ }
+ }
+
+ if (result.size === 0 && deps.opencodeJson) {
+ try {
+ const config = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig;
+ if (config.lsp) {
+ for (const [key, server] of Object.entries(config.lsp)) {
+ const resolved = resolveServer(key, server);
+ result.set(resolved.id, resolved);
+ }
+ }
+ } catch {
+ // ignore parse errors
+ }
+ }
+
+ if (result.size === 0) {
+ for (const [id, server] of Object.entries(BUILT_IN_REGISTRY)) {
+ result.set(id, server);
+ }
+ }
+
+ return [...result.values()];
+}
+
+function resolveServer(key: string, config: ServerConfig): ResolvedServer {
+ const id = config.id ?? key;
+ const name = config.name ?? id;
+ const extensions = config.extensions ?? [];
+ const rootMarkers = config.rootMarkers ?? [];
+
+ let sidecar: { readonly command: readonly string[] } | undefined;
+ if (config.watch) {
+ sidecar = { command: config.watch };
+ } else if (config.initialization) {
+ sidecar = detectSidecar(config.initialization);
+ }
+
+ const result: ResolvedServer = {
+ id,
+ name,
+ command: config.command,
+ extensions,
+ rootMarkers,
+ };
+ if (config.env) {
+ (result as { env?: Readonly<Record<string, string>> }).env = config.env;
+ }
+ if (config.initialization) {
+ (result as { initialization?: Readonly<Record<string, unknown>> }).initialization =
+ config.initialization;
+ }
+ if (sidecar) {
+ (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar;
+ }
+ return result;
+}
+
+function detectSidecar(
+ init: Readonly<Record<string, unknown>>,
+): { readonly command: readonly string[] } | undefined {
+ const luauLsp = init["luau-lsp"];
+ if (!luauLsp || typeof luauLsp !== "object") return undefined;
+ const luau = luauLsp as Record<string, unknown>;
+ const sourcemap = luau.sourcemap;
+ if (!sourcemap || typeof sourcemap !== "object") return undefined;
+ const sm = sourcemap as Record<string, unknown>;
+ if (sm.autogenerate !== true) return undefined;
+ const rojoProjectFile = sm.rojoProjectFile;
+ if (typeof rojoProjectFile !== "string") return undefined;
+ return {
+ command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"],
+ };
+}
diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts
new file mode 100644
index 0000000..9f2b6b4
--- /dev/null
+++ b/packages/lsp/src/diagnostics.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { DiagnosticsStore } from "./diagnostics.js";
+
+describe("diagnostics", () => {
+ it("formats diagnostics with severity, location, and message", () => {
+ const store = new DiagnosticsStore();
+ store.setPushDiagnostics({
+ uri: "file:///test.ts",
+ diagnostics: [
+ {
+ range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } },
+ severity: 1,
+ source: "typescript",
+ message: "Cannot find name 'hello'.",
+ },
+ ],
+ });
+
+ const formatted = store.format("file:///test.ts");
+ expect(formatted).toContain("ERROR");
+ expect(formatted).toContain("L1:6");
+ expect(formatted).toContain("Cannot find name 'hello'.");
+ expect(formatted).toContain("[typescript]");
+ });
+
+ it("merges push and pull diagnostics with deduplication", () => {
+ const store = new DiagnosticsStore();
+ const diag = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ severity: 1,
+ message: "Error",
+ };
+
+ store.setPushDiagnostics({
+ uri: "file:///test.ts",
+ diagnostics: [diag],
+ });
+ store.setPullDiagnostics("file:///test.ts", {
+ kind: "full",
+ items: [diag],
+ });
+
+ const merged = store.getMerged("file:///test.ts");
+ expect(merged).toHaveLength(1);
+ });
+
+ it("returns empty string when no diagnostics exist", () => {
+ const store = new DiagnosticsStore();
+ expect(store.format("file:///nonexistent.ts")).toBe("");
+ });
+});
diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts
new file mode 100644
index 0000000..ea18811
--- /dev/null
+++ b/packages/lsp/src/diagnostics.ts
@@ -0,0 +1,86 @@
+/**
+ * Diagnostics — merge push (textDocument/publishDiagnostics) + pull
+ * (textDocument/diagnostic) per file, dedupe, and format.
+ */
+
+export interface Diagnostic {
+ readonly range: {
+ readonly start: { readonly line: number; readonly character: number };
+ readonly end: { readonly line: number; readonly character: number };
+ };
+ readonly severity?: number;
+ readonly source?: string;
+ readonly message: string;
+ readonly code?: string | number;
+}
+
+export interface PublishDiagnosticsParams {
+ readonly uri: string;
+ readonly diagnostics: readonly Diagnostic[];
+}
+
+export interface DocumentDiagnosticReport {
+ readonly kind: "full" | "unchanged";
+ readonly items?: readonly Diagnostic[];
+}
+
+const severityNames: Record<number, string> = {
+ 1: "ERROR",
+ 2: "WARNING",
+ 3: "INFO",
+ 4: "HINT",
+};
+
+export class DiagnosticsStore {
+ private pushDiagnostics = new Map<string, readonly Diagnostic[]>();
+ private pullDiagnostics = new Map<string, readonly Diagnostic[]>();
+
+ setPushDiagnostics(params: PublishDiagnosticsParams): void {
+ this.pushDiagnostics.set(params.uri, params.diagnostics);
+ }
+
+ setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void {
+ if (report.kind === "full" && report.items) {
+ this.pullDiagnostics.set(uri, report.items);
+ }
+ }
+
+ getMerged(uri: string): readonly Diagnostic[] {
+ const push = this.pushDiagnostics.get(uri) ?? [];
+ const pull = this.pullDiagnostics.get(uri) ?? [];
+ return dedupeDiagnostics([...push, ...pull]);
+ }
+
+ format(uri: string): string {
+ const diags = this.getMerged(uri);
+ if (diags.length === 0) return "";
+ const lines: string[] = [];
+ for (const d of diags) {
+ const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN";
+ const line = d.range.start.line + 1;
+ const col = d.range.start.character + 1;
+ const src = d.source ? ` [${d.source}]` : "";
+ const code = d.code ? ` (${d.code})` : "";
+ lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`);
+ }
+ return lines.join("\n");
+ }
+}
+
+function diagnosticKey(d: Diagnostic): string {
+ const r = d.range;
+ return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`;
+}
+
+function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] {
+ const seen = new Set<string>();
+ const result: Diagnostic[] = [];
+ for (const d of diags) {
+ const key = diagnosticKey(d);
+ if (!seen.has(key)) {
+ seen.add(key);
+ result.push(d);
+ }
+ }
+ return result;
+}
diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts
new file mode 100644
index 0000000..486f66d
--- /dev/null
+++ b/packages/lsp/src/extension.ts
@@ -0,0 +1,120 @@
+/**
+ * LSP extension — manifest + activate(host).
+ *
+ * Builds the manager with real adapters, registers the lsp tool and
+ * lspServiceHandle, and wires deactivate to manager.shutdownAll().
+ */
+
+import type { Extension, HostAPI, ServiceHandle } from "@dispatch/kernel";
+import { defineService } from "@dispatch/kernel";
+import type { SpawnedProcess } from "./client.js";
+import { LspManager } from "./manager.js";
+import { createLspTool } from "./tool.js";
+import type { LspServerStatus, LspService } from "./types.js";
+
+export const lspServiceHandle: ServiceHandle<LspService> = defineService<LspService>("lsp");
+
+function realSpawn(
+ command: string[],
+ opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined },
+): SpawnedProcess {
+ const env: Record<string, string | undefined> = { ...process.env };
+ if (opts.env) {
+ for (const [key, value] of Object.entries(opts.env)) {
+ env[key] = value;
+ }
+ }
+ const proc = Bun.spawn(command, {
+ cwd: opts.cwd,
+ env: env as Record<string, string>,
+ stdin: "pipe",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ return {
+ stdin: proc.stdin,
+ stdout: proc.stdout,
+ stderr: proc.stderr,
+ pid: proc.pid,
+ kill: () => proc.kill(),
+ };
+}
+
+function realFileWatcher(
+ root: string,
+ onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void,
+): { readonly close: () => void } {
+ const { watch } = require("node:fs");
+ const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => {
+ if (!filename) return;
+ const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`;
+ const type = eventType === "rename" ? "create" : "change";
+ onEvent({ type, path: fullPath });
+ });
+ return { close: () => watcher.close() };
+}
+
+function realFs() {
+ return {
+ readText: async (path: string) => {
+ const file = Bun.file(path);
+ return file.text();
+ },
+ exists: async (path: string) => {
+ const file = Bun.file(path);
+ return file.exists();
+ },
+ };
+}
+
+export const extension: Extension = {
+ manifest: {
+ id: "lsp",
+ name: "Language Server Protocol",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { spawn: true, fs: true },
+ contributes: { tools: ["lsp"], services: ["lsp"] },
+ },
+ activate(host: HostAPI) {
+ const logger = host.logger;
+
+ const manager = new LspManager({
+ spawn: realSpawn,
+ fileWatcher: realFileWatcher,
+ fs: realFs(),
+ logger: {
+ info: (msg, attrs) =>
+ logger.info(msg, attrs as Record<string, string | number | boolean | null> | undefined),
+ warn: (msg, attrs) =>
+ logger.warn(msg, attrs as Record<string, string | number | boolean | null> | undefined),
+ error: (msg, attrs) => logger.error(msg, attrs as Record<string, unknown> | undefined),
+ },
+ });
+
+ const lspTool = createLspTool(manager);
+ host.defineTool(lspTool);
+
+ const service: LspService = {
+ async status(cwd: string): Promise<readonly LspServerStatus[]> {
+ return manager.status(cwd);
+ },
+ };
+ host.provideService(lspServiceHandle, service);
+
+ host.logger.info("LSP extension activated");
+
+ // Store manager for deactivate
+ (lspManagerStore as { manager: LspManager | null }).manager = manager;
+ },
+ deactivate() {
+ const store = lspManagerStore as { manager: LspManager | null };
+ store.manager?.shutdownAll();
+ store.manager = null;
+ },
+};
+
+// Module-scoped store for deactivate
+const lspManagerStore: { manager: LspManager | null } = { manager: null };
diff --git a/packages/lsp/src/framing.test.ts b/packages/lsp/src/framing.test.ts
new file mode 100644
index 0000000..7c51a16
--- /dev/null
+++ b/packages/lsp/src/framing.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { encode, FrameDecoder } from "./framing.js";
+
+describe("framing", () => {
+ it("encode/decode round-trips", () => {
+ const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } });
+ const encoded = encode(msg);
+ const decoder = new FrameDecoder();
+ const messages = decoder.decode(encoded);
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toBe(msg);
+ });
+
+ it("decoder reassembles a frame split across two chunks", () => {
+ const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" });
+ const encoded = encode(msg);
+ const mid = Math.floor(encoded.length / 2);
+ const chunk1 = encoded.slice(0, mid);
+ const chunk2 = encoded.slice(mid);
+
+ const decoder = new FrameDecoder();
+ const result1 = decoder.decode(chunk1);
+ expect(result1).toHaveLength(0);
+
+ const result2 = decoder.decode(chunk2);
+ expect(result2).toHaveLength(1);
+ expect(result2[0]).toBe(msg);
+ });
+
+ it("decoder yields two messages from one chunk", () => {
+ const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" });
+ const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" });
+ const encoded1 = encode(msg1);
+ const encoded2 = encode(msg2);
+
+ const combined = new Uint8Array(encoded1.length + encoded2.length);
+ combined.set(encoded1);
+ combined.set(encoded2, encoded1.length);
+
+ const decoder = new FrameDecoder();
+ const messages = decoder.decode(combined);
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toBe(msg1);
+ expect(messages[1]).toBe(msg2);
+ });
+});
diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts
new file mode 100644
index 0000000..3a8ab3a
--- /dev/null
+++ b/packages/lsp/src/framing.ts
@@ -0,0 +1,67 @@
+/**
+ * LSP Content-Length framing codec.
+ *
+ * The LSP base protocol uses Content-Length headers to frame JSON messages.
+ * `encode` wraps a JSON message with headers; `FrameDecoder` reassembles
+ * complete messages from streaming byte chunks (handles partial frames and
+ * multiple frames per chunk).
+ */
+
+const HEADER_SEP = "\r\n\r\n";
+const CONTENT_LENGTH_RE = /^Content-Length:\s*(\d+)/i;
+
+export function encode(msg: string): Uint8Array {
+ const body = new TextEncoder().encode(msg);
+ const header = `Content-Length: ${body.length}\r\n\r\n`;
+ const frame = new TextEncoder().encode(header);
+ const result = new Uint8Array(frame.length + body.length);
+ result.set(frame);
+ result.set(body, frame.length);
+ return result;
+}
+
+export class FrameDecoder {
+ private buffer = "";
+ private expectedLength: number | null = null;
+ private headerEnd = -1;
+
+ /**
+ * Feed raw bytes into the decoder. Returns all complete JSON messages
+ * that can be extracted from the accumulated buffer.
+ */
+ decode(chunk: Uint8Array): string[] {
+ this.buffer += new TextDecoder().decode(chunk);
+ const messages: string[] = [];
+
+ while (true) {
+ if (this.expectedLength === null) {
+ const headerEnd = this.buffer.indexOf(HEADER_SEP);
+ if (headerEnd === -1) break;
+
+ const headerPart = this.buffer.slice(0, headerEnd);
+ const match = CONTENT_LENGTH_RE.exec(headerPart);
+ if (!match?.[1]) {
+ this.buffer = this.buffer.slice(headerEnd + HEADER_SEP.length);
+ continue;
+ }
+ this.expectedLength = Number.parseInt(match[1], 10);
+ this.headerEnd = headerEnd;
+ }
+
+ const bodyStart = this.headerEnd + HEADER_SEP.length;
+ const available = this.buffer.length - bodyStart;
+
+ if (available >= this.expectedLength) {
+ const body = this.buffer.slice(bodyStart, bodyStart + this.expectedLength);
+ messages.push(body);
+ this.buffer = this.buffer.slice(bodyStart + this.expectedLength);
+ this.expectedLength = null;
+ this.headerEnd = -1;
+ } else {
+ break;
+ }
+ }
+
+ return messages;
+ }
+}
diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts
new file mode 100644
index 0000000..19f824b
--- /dev/null
+++ b/packages/lsp/src/index.ts
@@ -0,0 +1,29 @@
+export type {
+ ClientDeps,
+ FileWatcher,
+ FileWatcherHandle,
+ FsAccess,
+ SpawnedProcess,
+ SpawnProcess,
+} from "./client.js";
+export { LanguageServerClient } from "./client.js";
+export type { LspJsonConfig, OpencodeJsonConfig, ResolvedServer, ServerConfig } from "./config.js";
+export { resolveServers } from "./config.js";
+export type {
+ Diagnostic,
+ DocumentDiagnosticReport,
+ PublishDiagnosticsParams,
+} from "./diagnostics.js";
+export { DiagnosticsStore } from "./diagnostics.js";
+export { extension, lspServiceHandle } from "./extension.js";
+export { encode, FrameDecoder } from "./framing.js";
+export { languageId } from "./language.js";
+export type { Logger, ManagerDeps } from "./manager.js";
+export { LspManager } from "./manager.js";
+export { findRoot } from "./root.js";
+export type { JsonRpcMessage, NotificationHandler, RequestHandler, WriteFn } from "./rpc.js";
+export { JsonRpcConnection } from "./rpc.js";
+export { createLspTool } from "./tool.js";
+export type { LspServerState, LspServerStatus, LspService } from "./types.js";
+export type { FileChangeTypeValue, FileSystemWatcher, Registration } from "./watched-files.js";
+export { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js";
diff --git a/packages/lsp/src/language.test.ts b/packages/lsp/src/language.test.ts
new file mode 100644
index 0000000..3ec4b47
--- /dev/null
+++ b/packages/lsp/src/language.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { languageId } from "./language.js";
+
+describe("language", () => {
+ it("maps .ts to typescript", () => {
+ expect(languageId("file.ts")).toBe("typescript");
+ });
+
+ it("maps .tsx to typescriptreact", () => {
+ expect(languageId("file.tsx")).toBe("typescriptreact");
+ });
+
+ it("maps .js to javascript", () => {
+ expect(languageId("file.js")).toBe("javascript");
+ });
+
+ it("maps .luau to luau", () => {
+ expect(languageId("file.luau")).toBe("luau");
+ });
+
+ it("returns unknown for unrecognized extensions", () => {
+ expect(languageId("file.xyz")).toBe("unknown");
+ });
+
+ it("returns unknown for files without extensions", () => {
+ expect(languageId("Makefile")).toBe("unknown");
+ });
+});
diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts
new file mode 100644
index 0000000..214294e
--- /dev/null
+++ b/packages/lsp/src/language.ts
@@ -0,0 +1,36 @@
+/**
+ * Language ID mapping from file extensions.
+ */
+
+const extensionMap: Record<string, string> = {
+ ".ts": "typescript",
+ ".tsx": "typescriptreact",
+ ".mts": "typescript",
+ ".cts": "typescript",
+ ".js": "javascript",
+ ".jsx": "javascriptreact",
+ ".mjs": "javascript",
+ ".cjs": "javascript",
+ ".json": "json",
+ ".lua": "lua",
+ ".luau": "luau",
+ ".py": "python",
+ ".rs": "rust",
+ ".go": "go",
+ ".md": "markdown",
+ ".yaml": "yaml",
+ ".yml": "yaml",
+ ".toml": "toml",
+ ".css": "css",
+ ".html": "html",
+ ".sh": "shellscript",
+ ".bash": "shellscript",
+ ".zsh": "shellscript",
+};
+
+export function languageId(filePath: string): string {
+ const dotIdx = filePath.lastIndexOf(".");
+ if (dotIdx === -1) return "unknown";
+ const ext = filePath.slice(dotIdx).toLowerCase();
+ return extensionMap[ext] ?? "unknown";
+}
diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts
new file mode 100644
index 0000000..3e8e60e
--- /dev/null
+++ b/packages/lsp/src/manager.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from "vitest";
+import type { FileWatcher, FsAccess, SpawnedProcess, SpawnProcess } from "./client.js";
+import { encode } from "./framing.js";
+import { LspManager } from "./manager.js";
+
+function makeAutoHandshakeSpawn(): SpawnProcess {
+ return () => {
+ let messageHandler: ((data: Uint8Array) => void) | null = null;
+
+ const proc: SpawnedProcess = {
+ stdin: {
+ write: (bytes: Uint8Array) => {
+ // Parse the message to handle initialize handshake
+ const decoded = new TextDecoder().decode(bytes);
+ const headerEnd = decoded.indexOf("\r\n\r\n");
+ if (headerEnd === -1) return;
+ const json = decoded.slice(headerEnd + 4);
+ try {
+ const msg = JSON.parse(json);
+ if (msg.method === "initialize") {
+ // Send back initialize response
+ setTimeout(() => {
+ const response = JSON.stringify({
+ jsonrpc: "2.0",
+ id: msg.id,
+ result: { capabilities: {} },
+ });
+ messageHandler?.(encode(response));
+ }, 5);
+ }
+ // Ignore other messages
+ } catch {
+ // ignore parse errors
+ }
+ },
+ },
+ stdout: {
+ on: (_event: string, cb: (data: Uint8Array) => void) => {
+ messageHandler = cb;
+ },
+ },
+ pid: 12345,
+ kill: () => {},
+ };
+ return proc;
+ };
+}
+
+function noopFileWatcher(): FileWatcher {
+ return () => ({ close: () => {} });
+}
+
+function fakeFs(files: Record<string, string> = {}): FsAccess {
+ return {
+ readText: async (path) => files[path] ?? "",
+ exists: async (path) => path in files,
+ };
+}
+
+describe("manager", () => {
+ it("status(cwd) lazy-spawns matching servers and reports connected", async () => {
+ const manager = new LspManager({
+ spawn: makeAutoHandshakeSpawn(),
+ fileWatcher: noopFileWatcher(),
+ fs: fakeFs({
+ "/project/tsconfig.json": "{}",
+ "/project/.dispatch/lsp.json": JSON.stringify({
+ servers: {
+ test: {
+ command: ["test-lsp", "--stdio"],
+ extensions: [".ts"],
+ rootMarkers: ["tsconfig.json"],
+ },
+ },
+ }),
+ }),
+ });
+
+ const statuses = await manager.status("/project");
+ expect(statuses).toHaveLength(1);
+ expect(statuses[0]?.id).toBe("test");
+ expect(statuses[0]?.state).toBe("connected");
+ expect(statuses[0]?.root).toBe("/project");
+ }, 10000);
+
+ it("concurrent status for the same root spawns one process", async () => {
+ let spawnCount = 0;
+ const countingSpawn: SpawnProcess = () => {
+ spawnCount++;
+ return makeAutoHandshakeSpawn()([], { cwd: "/project" });
+ };
+
+ const manager = new LspManager({
+ spawn: countingSpawn,
+ fileWatcher: noopFileWatcher(),
+ fs: fakeFs({
+ "/project/tsconfig.json": "{}",
+ "/project/.dispatch/lsp.json": JSON.stringify({
+ servers: {
+ test: {
+ command: ["test-lsp"],
+ extensions: [".ts"],
+ rootMarkers: ["tsconfig.json"],
+ },
+ },
+ }),
+ }),
+ });
+
+ const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]);
+
+ expect(s1).toHaveLength(1);
+ expect(s2).toHaveLength(1);
+ expect(spawnCount).toBe(1);
+ }, 10000);
+
+ it("a failing spawn reports state:error and is not retried", async () => {
+ let spawnCount = 0;
+ const failingSpawn: SpawnProcess = () => {
+ spawnCount++;
+ throw new Error("spawn failed");
+ };
+
+ const manager = new LspManager({
+ spawn: failingSpawn,
+ fileWatcher: noopFileWatcher(),
+ fs: fakeFs({
+ "/project/tsconfig.json": "{}",
+ "/project/.dispatch/lsp.json": JSON.stringify({
+ servers: {
+ test: {
+ command: ["test-lsp"],
+ extensions: [".ts"],
+ rootMarkers: ["tsconfig.json"],
+ },
+ },
+ }),
+ }),
+ });
+
+ const s1 = await manager.status("/project");
+ expect(s1[0]?.state).toBe("error");
+ expect(spawnCount).toBe(1);
+
+ // Second call should not retry
+ const s2 = await manager.status("/project");
+ expect(s2[0]?.state).toBe("error");
+ expect(spawnCount).toBe(1);
+ });
+
+ it("shutdownAll kills all spawned processes (incl. sidecars)", async () => {
+ let killed = false;
+ const trackableSpawn: SpawnProcess = () => {
+ const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" });
+ return {
+ ...proc,
+ kill: () => {
+ killed = true;
+ },
+ };
+ };
+
+ const manager = new LspManager({
+ spawn: trackableSpawn,
+ fileWatcher: noopFileWatcher(),
+ fs: fakeFs({
+ "/project/tsconfig.json": "{}",
+ "/project/.dispatch/lsp.json": JSON.stringify({
+ servers: {
+ test: {
+ command: ["test-lsp"],
+ extensions: [".ts"],
+ rootMarkers: ["tsconfig.json"],
+ },
+ },
+ }),
+ }),
+ });
+
+ await manager.status("/project");
+ manager.shutdownAll();
+ expect(killed).toBe(true);
+ }, 10000);
+
+ it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => {
+ const manager = new LspManager({
+ spawn: makeAutoHandshakeSpawn(),
+ fileWatcher: noopFileWatcher(),
+ fs: fakeFs({
+ "/proj-a/.dispatch/lsp.json": JSON.stringify({
+ servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } },
+ }),
+ "/proj-b/opencode.json": JSON.stringify({
+ lsp: { b: { command: ["b-lsp"], extensions: [".b"] } },
+ }),
+ }),
+ });
+
+ const a = await manager.status("/proj-a");
+ const b = await manager.status("/proj-b");
+ expect(a.map((s) => s.id)).toEqual(["a"]);
+ expect(b.map((s) => s.id)).toEqual(["b"]);
+ }, 10000);
+});
diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts
new file mode 100644
index 0000000..7080c65
--- /dev/null
+++ b/packages/lsp/src/manager.ts
@@ -0,0 +1,215 @@
+/**
+ * Manager — lazy-spawn one client per (serverID, root); dedup concurrent
+ * spawns; track a broken set (no retry storm); status(cwd); shutdownAll().
+ */
+
+import { join } from "node:path";
+import {
+ type ClientDeps,
+ type FileWatcher,
+ type FsAccess,
+ LanguageServerClient,
+ type SpawnProcess,
+} from "./client.js";
+import { type ResolvedServer, resolveServers } from "./config.js";
+import { findRoot } from "./root.js";
+import type { LspServerState, LspServerStatus } from "./types.js";
+
+export type Logger = {
+ readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
+};
+
+export interface ManagerDeps {
+ readonly spawn: SpawnProcess;
+ readonly fileWatcher: FileWatcher;
+ readonly fs: FsAccess;
+ readonly logger?: Logger | undefined;
+}
+
+type ClientEntry = {
+ readonly client: LanguageServerClient;
+ readonly server: ResolvedServer;
+ readonly promise: Promise<void>;
+};
+
+export class LspManager {
+ private clients = new Map<string, ClientEntry>();
+ private broken = new Set<string>();
+ private spawning = new Map<string, Promise<void>>();
+ private deps: ManagerDeps;
+
+ constructor(deps: ManagerDeps) {
+ this.deps = deps;
+ }
+
+ async status(cwd: string): Promise<readonly LspServerStatus[]> {
+ // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox
+ // project) gets its own .dispatch/lsp.json or opencode.json, not a global one.
+ const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json"));
+ const opencodeJson = await this.readOrNull(join(cwd, "opencode.json"));
+ const servers = await resolveServers({
+ cwd,
+ dispatchLspJson,
+ opencodeJson,
+ exists: this.deps.fs.exists,
+ });
+
+ const results: LspServerStatus[] = [];
+
+ for (const server of servers) {
+ const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists);
+ const key = `${server.id}:${root}`;
+
+ if (this.broken.has(key)) {
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: "Previously failed to start",
+ };
+ results.push(status);
+ continue;
+ }
+
+ const existing = this.clients.get(key);
+ if (existing) {
+ const state = existing.client.getState();
+ const stateError = existing.client.getStateError();
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = stateError;
+ }
+ results.push(status);
+ continue;
+ }
+
+ try {
+ await this.spawnClient(server, root, key);
+ const entry = this.clients.get(key);
+ if (entry) {
+ const state = entry.client.getState();
+ const stateError = entry.client.getStateError();
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: mapState(state),
+ };
+ if (stateError !== undefined) {
+ (status as { error?: string }).error = stateError;
+ }
+ results.push(status);
+ }
+ } catch (err: unknown) {
+ this.broken.add(key);
+ const status: LspServerStatus = {
+ id: server.id,
+ name: server.name,
+ root,
+ extensions: server.extensions,
+ state: "error",
+ error: err instanceof Error ? err.message : String(err),
+ };
+ results.push(status);
+ }
+ }
+
+ return results;
+ }
+
+ getClient(serverId: string, root: string): LanguageServerClient | undefined {
+ const key = `${serverId}:${root}`;
+ return this.clients.get(key)?.client;
+ }
+
+ /** Read a config file's contents, or null if it is absent/unreadable. */
+ private async readOrNull(path: string): Promise<string | null> {
+ if (!(await this.deps.fs.exists(path))) return null;
+ try {
+ return await this.deps.fs.readText(path);
+ } catch {
+ return null;
+ }
+ }
+
+ private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const existingSpawn = this.spawning.get(key);
+ if (existingSpawn) return existingSpawn;
+
+ const spawnPromise = this.doSpawn(server, root, key);
+ this.spawning.set(key, spawnPromise);
+
+ try {
+ await spawnPromise;
+ } finally {
+ this.spawning.delete(key);
+ }
+ }
+
+ private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> {
+ const clientDeps: ClientDeps = {
+ spawn: this.deps.spawn,
+ fileWatcher: this.deps.fileWatcher,
+ fs: this.deps.fs,
+ command: server.command,
+ root,
+ serverId: server.id,
+ };
+ if (server.env) {
+ (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env;
+ }
+ if (server.initialization) {
+ (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization =
+ server.initialization;
+ }
+
+ const client = new LanguageServerClient(clientDeps);
+ const entry: ClientEntry = {
+ client,
+ server,
+ promise: client.start(),
+ };
+
+ this.clients.set(key, entry);
+ await entry.promise;
+
+ if (client.getState() === "error") {
+ this.broken.add(key);
+ this.deps.logger?.warn("LSP server failed to start", {
+ serverId: server.id,
+ root,
+ error: client.getStateError() ?? "unknown",
+ });
+ } else {
+ this.deps.logger?.info("LSP server connected", {
+ serverId: server.id,
+ root,
+ });
+ }
+ }
+
+ shutdownAll(): void {
+ for (const [key, entry] of this.clients) {
+ entry.client.shutdown();
+ this.deps.logger?.info("LSP server shutdown", { key });
+ }
+ this.clients.clear();
+ this.broken.clear();
+ this.spawning.clear();
+ }
+}
+
+function mapState(state: LspServerState): LspServerState {
+ return state;
+}
diff --git a/packages/lsp/src/root.test.ts b/packages/lsp/src/root.test.ts
new file mode 100644
index 0000000..ffe2e31
--- /dev/null
+++ b/packages/lsp/src/root.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { findRoot } from "./root.js";
+
+describe("root", () => {
+ it("findRoot returns nearest marker ancestor bounded by cwd", async () => {
+ const existingFiles = new Set([
+ "/project/src/components/tsconfig.json",
+ "/project/tsconfig.json",
+ ]);
+
+ const result = await findRoot(
+ "/project/src/components/widgets",
+ "/project",
+ ["tsconfig.json"],
+ async (path) => existingFiles.has(path),
+ );
+
+ expect(result).toBe("/project/src/components");
+ });
+
+ it("falls back to cwd", async () => {
+ const result = await findRoot(
+ "/project/src/deep/nested",
+ "/project",
+ ["tsconfig.json"],
+ async () => false,
+ );
+
+ expect(result).toBe("/project");
+ });
+
+ it("finds marker at start directory", async () => {
+ const existingFiles = new Set(["/project/tsconfig.json"]);
+
+ const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) =>
+ existingFiles.has(path),
+ );
+
+ expect(result).toBe("/project");
+ });
+
+ it("respects cwd boundary", async () => {
+ const existingFiles = new Set(["/tsconfig.json"]);
+
+ const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) =>
+ existingFiles.has(path),
+ );
+
+ expect(result).toBe("/project");
+ });
+});
diff --git a/packages/lsp/src/root.ts b/packages/lsp/src/root.ts
new file mode 100644
index 0000000..fc7814e
--- /dev/null
+++ b/packages/lsp/src/root.ts
@@ -0,0 +1,44 @@
+/**
+ * Root finder — nearest ancestor containing a marker file, bounded at cwd.
+ */
+
+export async function findRoot(
+ startDir: string,
+ cwd: string,
+ markers: readonly string[],
+ exists: (path: string) => Promise<boolean>,
+): Promise<string> {
+ const normalizedStart = normalizePath(startDir);
+ const normalizedCwd = normalizePath(cwd);
+
+ let current = normalizedStart;
+ while (true) {
+ for (const marker of markers) {
+ const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`;
+ if (await exists(markerPath)) {
+ return current;
+ }
+ }
+ if (current === normalizedCwd || current === "/") {
+ return normalizedCwd;
+ }
+ const parent = getParent(current);
+ if (parent === current) return normalizedCwd;
+ current = parent;
+ }
+}
+
+function normalizePath(p: string): string {
+ let normalized = p.replace(/\\/g, "/");
+ if (normalized.length > 1 && normalized.endsWith("/")) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized || "/";
+}
+
+function getParent(p: string): string {
+ if (p === "/") return "/";
+ const lastSlash = p.lastIndexOf("/");
+ if (lastSlash <= 0) return "/";
+ return p.slice(0, lastSlash) || "/";
+}
diff --git a/packages/lsp/src/rpc.test.ts b/packages/lsp/src/rpc.test.ts
new file mode 100644
index 0000000..a03870f
--- /dev/null
+++ b/packages/lsp/src/rpc.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { JsonRpcConnection } from "./rpc.js";
+
+function makeConnection(): { conn: JsonRpcConnection; messages: string[] } {
+ const messages: string[] = [];
+ const conn = new JsonRpcConnection((bytes) => {
+ const decoded = new TextDecoder().decode(bytes);
+ // Extract JSON from the LSP-framed message
+ const headerEnd = decoded.indexOf("\r\n\r\n");
+ if (headerEnd !== -1) {
+ messages.push(decoded.slice(headerEnd + 4));
+ }
+ });
+ return { conn, messages };
+}
+
+function frameResponse(id: number, result: unknown): string {
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
+}
+
+describe("rpc", () => {
+ it("sendRequest resolves by matching id", async () => {
+ const { conn, messages } = makeConnection();
+
+ const promise = conn.sendRequest("test/method", { key: "value" });
+ expect(messages).toHaveLength(1);
+
+ const rawSent = messages[0];
+ if (rawSent === undefined) throw new Error("expected a sent message");
+ const sent = JSON.parse(rawSent);
+ expect(sent.method).toBe("test/method");
+ expect(sent.params).toEqual({ key: "value" });
+ expect(sent.id).toBe(1);
+
+ conn.handleMessage(frameResponse(1, { ok: true }));
+ const result = await promise;
+ expect(result).toEqual({ ok: true });
+ });
+
+ it("onNotification dispatches by method", () => {
+ const { conn } = makeConnection();
+ let received: unknown = null;
+ conn.onNotification("test/notify", (params) => {
+ received = params;
+ });
+
+ conn.handleMessage(
+ JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }),
+ );
+ expect(received).toEqual({ data: 42 });
+ });
+
+ it("onRequest replies to a server-to-client request", async () => {
+ const { conn, messages } = makeConnection();
+
+ conn.onRequest("workspace/configuration", (params) => {
+ const { items } = params as { readonly items: readonly { readonly section?: string }[] };
+ return items.map(() => ({ setting: true }));
+ });
+
+ await conn.handleMessage(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 100,
+ method: "workspace/configuration",
+ params: { items: [{ section: "test" }] },
+ }),
+ );
+
+ // The response should be sent back
+ expect(messages).toHaveLength(1);
+ const rawResponse = messages[0];
+ if (rawResponse === undefined) throw new Error("expected a response message");
+ const response = JSON.parse(rawResponse);
+ expect(response.id).toBe(100);
+ expect(response.result).toEqual([{ setting: true }]);
+ });
+});
diff --git a/packages/lsp/src/rpc.ts b/packages/lsp/src/rpc.ts
new file mode 100644
index 0000000..45adf42
--- /dev/null
+++ b/packages/lsp/src/rpc.ts
@@ -0,0 +1,132 @@
+/**
+ * JSON-RPC connection over an injected write function.
+ *
+ * Provides sendRequest (correlated by id), sendNotification, onRequest,
+ * and onNotification. The caller feeds decoded JSON messages via `handleMessage`.
+ */
+
+import { encode } from "./framing.js";
+
+export type WriteFn = (bytes: Uint8Array) => void;
+
+export interface PendingRequest {
+ readonly resolve: (value: unknown) => void;
+ readonly reject: (reason: unknown) => void;
+}
+
+export type RequestHandler = (params: unknown) => unknown | Promise<unknown>;
+export type NotificationHandler = (params: unknown) => void;
+
+export interface JsonRpcMessage {
+ readonly jsonrpc: "2.0";
+ readonly id?: number | string | undefined;
+ readonly method?: string | undefined;
+ readonly params?: unknown;
+ readonly result?: unknown;
+ readonly error?:
+ | { readonly code: number; readonly message: string; readonly data?: unknown }
+ | undefined;
+}
+
+export class JsonRpcConnection {
+ private nextId = 1;
+ private pending = new Map<number | string, PendingRequest>();
+ private requestHandlers = new Map<string, RequestHandler>();
+ private notificationHandlers = new Map<string, NotificationHandler>();
+ private write: WriteFn;
+
+ constructor(write: WriteFn) {
+ this.write = write;
+ }
+
+ sendRequest(method: string, params?: unknown): Promise<unknown> {
+ const id = this.nextId++;
+ const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params };
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.sendMessage(msg);
+ });
+ }
+
+ sendNotification(method: string, params?: unknown): void {
+ const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params };
+ this.sendMessage(msg);
+ }
+
+ onRequest(method: string, handler: RequestHandler): void {
+ this.requestHandlers.set(method, handler);
+ }
+
+ onNotification(method: string, handler: NotificationHandler): void {
+ this.notificationHandlers.set(method, handler);
+ }
+
+ async handleMessage(json: string): Promise<void> {
+ const msg = JSON.parse(json) as JsonRpcMessage;
+ const { id, method } = msg;
+
+ if (id !== undefined && method !== undefined) {
+ await this.handleIncomingRequest(id, method, msg.params);
+ } else if (method !== undefined) {
+ this.handleIncomingNotification(method, msg.params);
+ } else if (id !== undefined) {
+ this.handleResponse(id, msg);
+ }
+ }
+
+ private sendMessage(msg: JsonRpcMessage): void {
+ this.write(encode(JSON.stringify(msg)));
+ }
+
+ private handleResponse(id: number | string, msg: JsonRpcMessage): void {
+ const entry = this.pending.get(id);
+ if (!entry) return;
+ this.pending.delete(id);
+ if (msg.error) {
+ entry.reject(new Error(msg.error.message));
+ } else {
+ entry.resolve(msg.result);
+ }
+ }
+
+ private async handleIncomingRequest(
+ id: number | string,
+ method: string,
+ params: unknown,
+ ): Promise<void> {
+ const handler = this.requestHandlers.get(method);
+ if (!handler) {
+ this.sendMessage({
+ jsonrpc: "2.0",
+ id,
+ error: { code: -32601, message: `Method not found: ${method}` },
+ });
+ return;
+ }
+ try {
+ const result = await handler(params);
+ this.sendMessage({ jsonrpc: "2.0", id, result });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.sendMessage({
+ jsonrpc: "2.0",
+ id,
+ error: { code: -32603, message },
+ });
+ }
+ }
+
+ private handleIncomingNotification(method: string, params: unknown): void {
+ const handler = this.notificationHandlers.get(method);
+ if (handler) {
+ handler(params);
+ }
+ }
+
+ dispose(): void {
+ for (const entry of this.pending.values()) {
+ entry.reject(new Error("Connection closed"));
+ }
+ this.pending.clear();
+ }
+}
diff --git a/packages/lsp/src/tool.test.ts b/packages/lsp/src/tool.test.ts
new file mode 100644
index 0000000..03787ae
--- /dev/null
+++ b/packages/lsp/src/tool.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it } from "vitest";
+import type { LspManager } from "./manager.js";
+import { createLspTool } from "./tool.js";
+
+function stubManager(overrides?: Partial<LspManager>): LspManager {
+ return {
+ status: async () => [],
+ getClient: () => undefined,
+ shutdownAll: () => {},
+ ...overrides,
+ } as unknown as LspManager;
+}
+
+describe("tool", () => {
+ it("diagnostics formats errors", async () => {
+ const tool = createLspTool(stubManager());
+ const result = await tool.execute(
+ { operation: "diagnostics", path: "test.ts" },
+ {
+ toolCallId: "test",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => ({}) as never,
+ span: () => ({}) as never,
+ },
+ cwd: "/project",
+ },
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("No language server configured");
+ });
+
+ it("position ops convert 1-based to 0-based", async () => {
+ let receivedPosition: { line: number; character: number } | null = null;
+
+ const mockClient = {
+ getState: () => "connected" as const,
+ getStateError: () => undefined,
+ request: async (method: string, params: unknown) => {
+ if (method === "textDocument/hover") {
+ receivedPosition = (params as { position: { line: number; character: number } }).position;
+ return { contents: { value: "hover result" } };
+ }
+ return null;
+ },
+ waitForDiagnostics: async () => "",
+ };
+
+ const tool = createLspTool(
+ stubManager({
+ status: async () => [
+ {
+ id: "ts",
+ name: "TypeScript",
+ root: "/project",
+ extensions: [".ts"],
+ state: "connected",
+ },
+ ],
+ getClient: () => mockClient as never,
+ }),
+ );
+
+ const result = await tool.execute(
+ { operation: "hover", path: "test.ts", line: 5, character: 10 },
+ {
+ toolCallId: "test",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => ({}) as never,
+ span: () => ({}) as never,
+ },
+ cwd: "/project",
+ },
+ );
+
+ expect(receivedPosition).toEqual({ line: 4, character: 9 });
+ expect(result.content).toBe("hover result");
+ });
+
+ it("path resolved against ctx.cwd", async () => {
+ let receivedUri: string | null = null;
+
+ const mockClient = {
+ getState: () => "connected" as const,
+ getStateError: () => undefined,
+ request: async (method: string, params: unknown) => {
+ if (method === "textDocument/hover") {
+ receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri;
+ return { contents: { value: "ok" } };
+ }
+ return null;
+ },
+ waitForDiagnostics: async () => "",
+ };
+
+ const tool = createLspTool(
+ stubManager({
+ status: async () => [
+ {
+ id: "ts",
+ name: "TypeScript",
+ root: "/project",
+ extensions: [".ts"],
+ state: "connected",
+ },
+ ],
+ getClient: () => mockClient as never,
+ }),
+ );
+
+ await tool.execute(
+ { operation: "hover", path: "src/test.ts", line: 1, character: 1 },
+ {
+ toolCallId: "test",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => ({}) as never,
+ span: () => ({}) as never,
+ },
+ cwd: "/project",
+ },
+ );
+
+ expect(receivedUri).toBe("file:///project/src/test.ts");
+ });
+
+ it("position op without line/character returns isError", async () => {
+ const tool = createLspTool(
+ stubManager({
+ status: async () => [
+ {
+ id: "ts",
+ name: "TypeScript",
+ root: "/project",
+ extensions: [".ts"],
+ state: "connected",
+ },
+ ],
+ }),
+ );
+
+ const result = await tool.execute(
+ { operation: "hover", path: "test.ts" },
+ {
+ toolCallId: "test",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => ({}) as never,
+ span: () => ({}) as never,
+ },
+ cwd: "/project",
+ },
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("requires both");
+ });
+});
diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts
new file mode 100644
index 0000000..bc4b41f
--- /dev/null
+++ b/packages/lsp/src/tool.ts
@@ -0,0 +1,225 @@
+/**
+ * The lsp tool — model-facing tool contract.
+ *
+ * Operations: diagnostics, hover, definition, references, documentSymbol.
+ */
+
+import { resolve } from "node:path";
+import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
+import type { LspManager } from "./manager.js";
+
+type Operation = "diagnostics" | "hover" | "definition" | "references" | "documentSymbol";
+
+const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]);
+
+interface ValidatedArgs {
+ readonly operation: Operation;
+ readonly path: string;
+ readonly line?: number | undefined;
+ readonly character?: number | undefined;
+}
+
+function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawOp = obj.operation;
+ if (typeof rawOp !== "string") {
+ return { error: 'Error: Missing "operation" parameter (must be a string).' };
+ }
+ const validOps: ReadonlySet<string> = new Set([
+ "diagnostics",
+ "hover",
+ "definition",
+ "references",
+ "documentSymbol",
+ ]);
+ if (!validOps.has(rawOp)) {
+ return {
+ error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`,
+ };
+ }
+ const operation = rawOp as Operation;
+
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
+ return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' };
+ }
+
+ let line: number | undefined;
+ let character: number | undefined;
+
+ if (obj.line !== undefined) {
+ const n = Number(obj.line);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' };
+ }
+ line = Math.floor(n);
+ }
+
+ if (obj.character !== undefined) {
+ const n = Number(obj.character);
+ if (!Number.isFinite(n) || n < 1) {
+ return {
+ error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).',
+ };
+ }
+ character = Math.floor(n);
+ }
+
+ const result: ValidatedArgs = { operation, path: rawPath };
+ if (line !== undefined) {
+ (result as { line?: number }).line = line;
+ }
+ if (character !== undefined) {
+ (result as { character?: number }).character = character;
+ }
+ return result;
+}
+
+function resolveFilePath(filePath: string, cwd: string): string {
+ const resolved = resolve(cwd, filePath);
+ const normalizedCwd = resolve(cwd);
+ if (!resolved.startsWith(normalizedCwd)) {
+ return normalizedCwd;
+ }
+ return resolved;
+}
+
+/** Convert validated 1-based line/character to an LSP 0-based position. */
+function toPosition(
+ line: number | undefined,
+ character: number | undefined,
+): { readonly line: number; readonly character: number } {
+ if (line === undefined || character === undefined) {
+ throw new Error("Position operations require both line and character.");
+ }
+ return { line: line - 1, character: character - 1 };
+}
+
+export function createLspTool(manager: LspManager): ToolContract {
+ return {
+ name: "lsp",
+ description:
+ "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.",
+ parameters: {
+ type: "object",
+ properties: {
+ operation: {
+ type: "string",
+ enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"],
+ description: "The LSP operation to perform.",
+ },
+ path: {
+ type: "string",
+ description: "File path relative to the workspace.",
+ },
+ line: {
+ type: "number",
+ description: "Line number (1-based). Required for hover, definition, references.",
+ },
+ character: {
+ type: "number",
+ description: "Character position (1-based). Required for hover, definition, references.",
+ },
+ },
+ required: ["operation", "path"],
+ },
+ concurrencySafe: true,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { operation, path: filePath, line, character } = validated;
+ const cwd = ctx.cwd ?? process.cwd();
+ const absolutePath = resolveFilePath(filePath, cwd);
+
+ if (POSITION_OPS.has(operation)) {
+ if (line === undefined || character === undefined) {
+ return {
+ content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`,
+ isError: true,
+ };
+ }
+ }
+
+ try {
+ const statuses = await manager.status(cwd);
+ if (statuses.length === 0) {
+ return { content: "No language server configured for this workspace.", isError: true };
+ }
+
+ const connected = statuses.find((s) => s.state === "connected");
+ if (!connected) {
+ const first = statuses[0];
+ const detail = first
+ ? `"${first.name}" is not connected (state: ${first.state})`
+ : "is not connected";
+ return {
+ content: `Language server ${detail}.`,
+ isError: true,
+ };
+ }
+
+ // Find the client for this server
+ const client = manager.getClient(connected.id, connected.root);
+ if (!client) {
+ return { content: "Language server client not available.", isError: true };
+ }
+
+ switch (operation) {
+ case "diagnostics": {
+ const diags = await client.waitForDiagnostics(absolutePath);
+ return { content: diags || "No diagnostics found." };
+ }
+ case "hover": {
+ const result = await client.request("textDocument/hover", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No hover information available." };
+ const hover = result as { readonly contents?: { readonly value?: string } | string };
+ const content =
+ typeof hover.contents === "string"
+ ? hover.contents
+ : (hover.contents?.value ?? "No hover information available.");
+ return { content };
+ }
+ case "definition": {
+ const result = await client.request("textDocument/definition", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No definition found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "references": {
+ const result = await client.request("textDocument/references", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ context: { includeDeclaration: true },
+ });
+ if (!result) return { content: "No references found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "documentSymbol": {
+ const result = await client.request("textDocument/documentSymbol", {
+ textDocument: { uri: `file://${absolutePath}` },
+ });
+ if (!result) return { content: "No symbols found." };
+ return { content: JSON.stringify(result) };
+ }
+ }
+ } catch (err: unknown) {
+ return {
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ },
+ };
+}
diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts
new file mode 100644
index 0000000..f89f84d
--- /dev/null
+++ b/packages/lsp/src/types.ts
@@ -0,0 +1,23 @@
+/**
+ * Shared types for the LSP extension.
+ */
+
+export type LspServerState = "connected" | "starting" | "error" | "not-started";
+
+export interface LspServerStatus {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: LspServerState;
+ readonly error?: string | undefined;
+}
+
+export interface LspService {
+ /**
+ * Resolve the language servers configured for `cwd`, ensure each is spawned +
+ * initialized (lazy connect), and report live state. Never throws for a single
+ * server's failure — reflect it as state:"error" with a short `error`.
+ */
+ status(cwd: string): Promise<readonly LspServerStatus[]>;
+}
diff --git a/packages/lsp/src/watched-files.test.ts b/packages/lsp/src/watched-files.test.ts
new file mode 100644
index 0000000..4e598e1
--- /dev/null
+++ b/packages/lsp/src/watched-files.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js";
+
+describe("watched-files", () => {
+ it("register stores workspace/didChangeWatchedFiles watchers", () => {
+ const registry = new WatchedFilesRegistry();
+
+ registry.applyRegister({
+ id: "reg-1",
+ method: "workspace/didChangeWatchedFiles",
+ registerOptions: {
+ watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }],
+ },
+ });
+
+ const watchers = registry.getAllWatchers();
+ expect(watchers).toHaveLength(2);
+ expect(watchers[0]?.globPattern).toBe("**/*.luau");
+ expect(watchers[1]?.globPattern).toBe("sourcemap.json");
+ });
+
+ it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => {
+ const registry = new WatchedFilesRegistry();
+
+ registry.applyRegister({
+ id: "reg-1",
+ method: "workspace/didChangeWatchedFiles",
+ registerOptions: {
+ watchers: [{ globPattern: "**/*.luau" }],
+ },
+ });
+
+ expect(registry.matches("src/main.luau")).toBe(true);
+ expect(registry.matches("src/nested/deep/file.luau")).toBe(true);
+ expect(registry.matches("src/main.ts")).toBe(false);
+
+ expect(FileChangeType.Created).toBe(1);
+ expect(FileChangeType.Changed).toBe(2);
+ expect(FileChangeType.Deleted).toBe(3);
+ });
+
+ it("unregisterCapability stops forwarding", () => {
+ const registry = new WatchedFilesRegistry();
+
+ registry.applyRegister({
+ id: "reg-1",
+ method: "workspace/didChangeWatchedFiles",
+ registerOptions: {
+ watchers: [{ globPattern: "**/*.luau" }],
+ },
+ });
+
+ expect(registry.matches("src/main.luau")).toBe(true);
+
+ registry.applyUnregister({
+ id: "reg-1",
+ method: "workspace/didChangeWatchedFiles",
+ });
+
+ expect(registry.matches("src/main.luau")).toBe(false);
+ expect(registry.getAllWatchers()).toHaveLength(0);
+ });
+
+ it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => {
+ // **/*.luau
+ expect(globMatch("**/*.luau", "src/main.luau")).toBe(true);
+ expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true);
+ expect(globMatch("**/*.luau", "file.luau")).toBe(true);
+ expect(globMatch("**/*.luau", "src/main.ts")).toBe(false);
+
+ // sourcemap.json (literal)
+ expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true);
+ expect(globMatch("sourcemap.json", "other.json")).toBe(false);
+
+ // **/sourcemap.json
+ expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true);
+ expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true);
+ expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true);
+ expect(globMatch("**/sourcemap.json", "other.json")).toBe(false);
+ });
+});
diff --git a/packages/lsp/src/watched-files.ts b/packages/lsp/src/watched-files.ts
new file mode 100644
index 0000000..e23df89
--- /dev/null
+++ b/packages/lsp/src/watched-files.ts
@@ -0,0 +1,110 @@
+/**
+ * Watched-files registration state machine + glob matcher.
+ *
+ * Manages `workspace/didChangeWatchedFiles` registrations from the server.
+ * `applyRegister` stores watchers, `applyUnregister` removes them, and
+ * `matches(path)` tests a file path against all registered glob patterns.
+ *
+ * Glob matching supports double-star (any path segments), star (within a segment),
+ * and literals.
+ */
+
+export interface FileSystemWatcher {
+ readonly globPattern: string;
+ readonly kind?: number;
+}
+
+export interface DidChangeWatchedFilesRegistrationOptions {
+ readonly watchers: readonly FileSystemWatcher[];
+}
+
+export interface Registration {
+ readonly id: string;
+ readonly method: string;
+ readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions;
+}
+
+export const FileChangeType = {
+ Created: 1,
+ Changed: 2,
+ Deleted: 3,
+} as const;
+
+export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType];
+
+export class WatchedFilesRegistry {
+ private watchers = new Map<string, readonly FileSystemWatcher[]>();
+
+ applyRegister(registration: Registration): void {
+ if (registration.method !== "workspace/didChangeWatchedFiles") return;
+ const opts = registration.registerOptions;
+ if (!opts?.watchers) return;
+ this.watchers.set(registration.id, opts.watchers);
+ }
+
+ applyUnregister(unregistration: { readonly id: string; readonly method: string }): void {
+ if (unregistration.method !== "workspace/didChangeWatchedFiles") return;
+ this.watchers.delete(unregistration.id);
+ }
+
+ matches(filePath: string): boolean {
+ for (const watchers of this.watchers.values()) {
+ for (const w of watchers) {
+ if (globMatch(w.globPattern, filePath)) return true;
+ }
+ }
+ return false;
+ }
+
+ getAllWatchers(): readonly FileSystemWatcher[] {
+ const result: FileSystemWatcher[] = [];
+ for (const watchers of this.watchers.values()) {
+ for (const w of watchers) {
+ result.push(w);
+ }
+ }
+ return result;
+ }
+}
+
+export function globMatch(pattern: string, filePath: string): boolean {
+ const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/");
+ const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/");
+ const regex = globToRegex(normalizedPattern);
+ return regex.test(normalizedPath);
+}
+
+function globToRegex(glob: string): RegExp {
+ let regex = "^";
+ let i = 0;
+ while (i < glob.length) {
+ const ch = glob[i] ?? "";
+ if (ch === "*" && glob[i + 1] === "*") {
+ if (glob[i + 2] === "/") {
+ regex += "(?:.+/)?";
+ i += 3;
+ } else {
+ regex += ".*";
+ i += 2;
+ }
+ } else if (ch === "*") {
+ regex += "[^/]*";
+ i++;
+ } else if (ch === "?") {
+ regex += "[^/]";
+ i++;
+ } else if (ch === ".") {
+ regex += "\\.";
+ i++;
+ } else {
+ regex += escapeRegex(ch);
+ i++;
+ }
+ }
+ regex += "$";
+ return new RegExp(regex);
+}
+
+function escapeRegex(ch: string): string {
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
diff --git a/packages/lsp/tsconfig.json b/packages/lsp/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/lsp/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}