summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store.test.ts46
-rw-r--r--packages/conversation-store/src/store.ts16
-rw-r--r--packages/host-bin/package.json1
-rw-r--r--packages/host-bin/src/config.test.ts20
-rw-r--r--packages/host-bin/src/config.ts8
-rw-r--r--packages/host-bin/src/main.ts9
-rw-r--r--packages/host-bin/tsconfig.json3
-rw-r--r--packages/kernel/src/logging/logger.test.ts45
-rw-r--r--packages/kernel/src/logging/logger.ts11
-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
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts244
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts41
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/contract.types.test.ts118
-rw-r--r--packages/transport-contract/src/index.ts43
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts180
-rw-r--r--packages/transport-http/src/app.ts90
-rw-r--r--packages/transport-http/src/extension.ts13
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts20
-rw-r--r--packages/transport-http/tsconfig.json1
48 files changed, 3652 insertions, 14 deletions
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts
index 0ba25d8..e7204db 100644
--- a/packages/conversation-store/src/keys.ts
+++ b/packages/conversation-store/src/keys.ts
@@ -45,3 +45,7 @@ export function parseMetricsOrdinal(key: string): number {
const n = Number.parseInt(last, 10);
return Number.isNaN(n) ? -1 : n;
}
+
+export function cwdKey(conversationId: string): string {
+ return `conv:${conversationId}:cwd`;
+}
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts
index a82b4cc..49b0e58 100644
--- a/packages/conversation-store/src/store.test.ts
+++ b/packages/conversation-store/src/store.test.ts
@@ -750,3 +750,49 @@ describe("ConversationStore reconcile.repair span", () => {
}
});
});
+
+describe("ConversationStore cwd", () => {
+ let storage: StorageNamespace;
+
+ beforeEach(() => {
+ storage = createMemoryStorage();
+ });
+
+ it("setCwd then getCwd returns the value", async () => {
+ const store = createConversationStore(storage);
+ await store.setCwd("conv1", "/home/user/project");
+ const result = await store.getCwd("conv1");
+ expect(result).toBe("/home/user/project");
+ });
+
+ it("getCwd returns null when never set", async () => {
+ const store = createConversationStore(storage);
+ const result = await store.getCwd("conv_unknown");
+ expect(result).toBeNull();
+ });
+
+ it("setCwd is an upsert (second set overwrites)", async () => {
+ const store = createConversationStore(storage);
+ await store.setCwd("conv1", "/first/path");
+ await store.setCwd("conv1", "/second/path");
+ const result = await store.getCwd("conv1");
+ expect(result).toBe("/second/path");
+ });
+
+ it("cwd persists across a fresh store instance on the same db file", async () => {
+ const store1 = createConversationStore(storage);
+ await store1.setCwd("conv1", "/persisted/path");
+
+ const store2 = createConversationStore(storage);
+ const result = await store2.getCwd("conv1");
+ expect(result).toBe("/persisted/path");
+ });
+
+ it("cwd of one conversation does not leak into another", async () => {
+ const store = createConversationStore(storage);
+ await store.setCwd("convA", "/path/a");
+ await store.setCwd("convB", "/path/b");
+ expect(await store.getCwd("convA")).toBe("/path/a");
+ expect(await store.getCwd("convB")).toBe("/path/b");
+ });
+});
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index 6b91d58..0948f64 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -11,6 +11,7 @@ import { defineService } from "@dispatch/kernel";
import {
chunkKey,
chunkPrefix,
+ cwdKey,
metricsKey,
metricsPrefix,
metricsSeqKey,
@@ -28,6 +29,10 @@ export interface ConversationStore {
) => Promise<readonly StoredChunk[]>;
readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>;
readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>;
+ /** The persisted working directory for a conversation, or null if never set. */
+ readonly getCwd: (conversationId: string) => Promise<string | null>;
+ /** Persist (upsert) the working directory for a conversation. */
+ readonly setCwd: (conversationId: string, cwd: string) => Promise<void>;
}
export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
@@ -154,5 +159,16 @@ export function createConversationStore(
return result;
},
+
+ async getCwd(conversationId) {
+ return await storage.get(cwdKey(conversationId));
+ },
+
+ async setCwd(conversationId, cwd) {
+ await storage.set(cwdKey(conversationId), cwd);
+ if (logger !== undefined) {
+ logger.debug("cwd set", { conversationId });
+ }
+ },
};
}
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index 0d2b817..27a4a1b 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -20,6 +20,7 @@
"@dispatch/tool-edit-file": "workspace:*",
"@dispatch/tool-write-file": "workspace:*",
"@dispatch/journal-sink": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
"@dispatch/surface-loaded-extensions": "workspace:*",
"@dispatch/surface-registry": "workspace:*",
"@dispatch/transport-ws": "workspace:*"
diff --git a/packages/host-bin/src/config.test.ts b/packages/host-bin/src/config.test.ts
index a5cfde1..fc74a79 100644
--- a/packages/host-bin/src/config.test.ts
+++ b/packages/host-bin/src/config.test.ts
@@ -51,6 +51,26 @@ describe("envToConfigMap", () => {
expect(result["provider.openai-compat.baseURL"]).toBeUndefined();
expect(result["provider.openai-compat.model"]).toBeUndefined();
});
+
+ it("maps SURFACE_WS_PORT to surfaceWsPort", () => {
+ const result = envToConfigMap({ SURFACE_WS_PORT: "24206" });
+ expect(result.surfaceWsPort).toBe(24206);
+ });
+
+ it("ignores a non-numeric SURFACE_WS_PORT", () => {
+ const result = envToConfigMap({ SURFACE_WS_PORT: "abc" });
+ expect(result.surfaceWsPort).toBeUndefined();
+ });
+
+ it("ignores a non-positive SURFACE_WS_PORT", () => {
+ const result = envToConfigMap({ SURFACE_WS_PORT: "0" });
+ expect(result.surfaceWsPort).toBeUndefined();
+ });
+
+ it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => {
+ const result = envToConfigMap({});
+ expect(result.surfaceWsPort).toBeUndefined();
+ });
});
describe("configMapToAccess", () => {
diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts
index f79ef4b..9a22c00 100644
--- a/packages/host-bin/src/config.ts
+++ b/packages/host-bin/src/config.ts
@@ -39,6 +39,14 @@ export function envToConfigMap(
}
}
+ const surfaceWsPort = env.SURFACE_WS_PORT;
+ if (surfaceWsPort !== undefined) {
+ const n = Number(surfaceWsPort);
+ if (Number.isFinite(n) && n > 0) {
+ map.surfaceWsPort = n;
+ }
+ }
+
return map;
}
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 1594dcc..420f12e 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -19,6 +19,7 @@ import {
type SecretsAccess,
type StorageNamespace,
} from "@dispatch/kernel";
+import { extension as lspExt } from "@dispatch/lsp";
import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat";
import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator";
import { extension as skillsExt } from "@dispatch/skills";
@@ -75,6 +76,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [
sessionOrchestratorExt,
skillsExt,
cacheWarmingExt,
+ lspExt,
createTransportHttpExtension(),
// Surface extensions — dependency order: surface-registry first, then consumers.
createSurfaceRegistryExtension(),
@@ -169,8 +171,13 @@ async function boot(): Promise<void> {
}
}
+ let shuttingDown = false;
const shutdown = async () => {
- logger.info("Shutting down — draining collector");
+ if (shuttingDown) return;
+ shuttingDown = true;
+ logger.info("Shutting down — deactivating extensions");
+ await host.deactivate();
+ logger.info("Draining collector");
await supervisor.stop();
process.exit(0);
};
diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json
index 77de667..efe3815 100644
--- a/packages/host-bin/tsconfig.json
+++ b/packages/host-bin/tsconfig.json
@@ -15,6 +15,7 @@
{ "path": "../skills" },
{ "path": "../throughput-store" },
{ "path": "../transport-http" },
- { "path": "../transport-ws" }
+ { "path": "../transport-ws" },
+ { "path": "../lsp" }
]
}
diff --git a/packages/kernel/src/logging/logger.test.ts b/packages/kernel/src/logging/logger.test.ts
new file mode 100644
index 0000000..5d7bf45
--- /dev/null
+++ b/packages/kernel/src/logging/logger.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import type { LogDeps, LogRecord, LogSink } from "../contracts/logging.js";
+import { createLogger } from "./logger.js";
+
+function harness() {
+ let idCounter = 0;
+ const deps: LogDeps = {
+ now: () => 1000 + idCounter * 10,
+ newId: () => `span-${++idCounter}`,
+ };
+ const records: LogRecord[] = [];
+ const sink: LogSink = { emit: (r) => records.push(r) };
+ return { logger: createLogger({ extensionId: "test" }, sink, deps), records };
+}
+
+describe("createLogger child-bound attributes", () => {
+ it("merges child-bound attrs into BOTH span-open and span-close records", () => {
+ const { logger, records } = harness();
+ // Bind `warm: true` via child() — mirrors the cache-warming capture path.
+ const warmLogger = logger.child({ conversationId: "c1", attrs: { warm: true } });
+
+ const span = warmLogger.span("provider.request", { model: "x" });
+ span.end({ attrs: { "usage.cacheReadTokens": 0 } });
+
+ const open = records.find((r) => r.kind === "span-open");
+ const close = records.find((r) => r.kind === "span-close");
+
+ // Open carries the bound attr (pre-existing behavior).
+ expect(open?.attributes?.warm).toBe(true);
+ // Close MUST carry it too, so a `warm = true` query finds the closed span
+ // (with its usage/status) — not just the open record.
+ expect(close?.attributes?.warm).toBe(true);
+ // Span-specific attrs from span()/end() are still present on close.
+ expect(close?.attributes?.model).toBe("x");
+ expect(close?.attributes?.["usage.cacheReadTokens"]).toBe(0);
+ });
+
+ it("omits attributes entirely when neither bound nor span attrs exist", () => {
+ const { logger, records } = harness();
+ const span = logger.span("bare");
+ span.end();
+ const close = records.find((r) => r.kind === "span-close");
+ expect(close?.attributes).toBeUndefined();
+ });
+});
diff --git a/packages/kernel/src/logging/logger.ts b/packages/kernel/src/logging/logger.ts
index 70bc18c..4d2a609 100644
--- a/packages/kernel/src/logging/logger.ts
+++ b/packages/kernel/src/logging/logger.ts
@@ -194,6 +194,15 @@ export function createLogger(
}
const hasAttrs = Object.keys(spanAttrsMutable).length > 0;
+ // Merge child-bound default attrs (state.attrs) the SAME way span-open
+ // does (buildSpanOpen). Without this, an attribute bound via
+ // `logger.child({ attrs })` appears on the span-open record but NOT the
+ // span-close record — so a query like `warm = true` can't find the
+ // closed span (with its usage/status). Open and close must agree.
+ const mergedCloseAttrs = mergeAttributes(
+ state.attrs,
+ hasAttrs ? spanAttrsMutable : undefined,
+ );
const hasLinks = links.length > 0;
const base = {
kind: "span-close" as const,
@@ -209,7 +218,7 @@ export function createLogger(
...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}),
...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}),
...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}),
- ...(hasAttrs ? { attributes: { ...spanAttrsMutable } } : {}),
+ ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}),
...(hasLinks ? { links: [...links] } : {}),
...(outcome?.body !== undefined ? { body: outcome.body } : {}),
};
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" }]
+}
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index ba4912a..33deb15 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -3,8 +3,10 @@ import type {
AgentEvent,
ChatMessage,
EventHookDescriptor,
+ Logger,
ProviderContract,
ProviderEvent,
+ ProviderStreamOptions,
RunTurnInput,
RunTurnResult,
StoredChunk,
@@ -24,12 +26,15 @@ import type { ToolAssembly } from "./tools-filter.js";
function createInMemoryStore(): ConversationStore & {
readonly data: Map<string, ChatMessage[]>;
readonly metricsData: Map<string, TurnMetrics[]>;
+ readonly cwdData: Map<string, string>;
} {
const data = new Map<string, ChatMessage[]>();
const metricsData = new Map<string, TurnMetrics[]>();
+ const cwdData = new Map<string, string>();
return {
data,
metricsData,
+ cwdData,
async append(conversationId, messages) {
const existing = data.get(conversationId) ?? [];
data.set(conversationId, [...existing, ...messages]);
@@ -58,6 +63,12 @@ function createInMemoryStore(): ConversationStore & {
async loadMetrics(conversationId) {
return [...(metricsData.get(conversationId) ?? [])];
},
+ async getCwd(conversationId) {
+ return cwdData.get(conversationId) ?? null;
+ },
+ async setCwd(conversationId, cwd) {
+ cwdData.set(conversationId, cwd);
+ },
};
}
@@ -518,6 +529,12 @@ describe("turn-sealed event", () => {
async loadMetrics(conversationId) {
return store.loadMetrics(conversationId);
},
+ async getCwd(conversationId) {
+ return store.getCwd(conversationId);
+ },
+ async setCwd(conversationId, cwd) {
+ await store.setCwd(conversationId, cwd);
+ },
};
const { orchestrator } = createSessionOrchestrator({
@@ -565,6 +582,10 @@ describe("turn-sealed event", () => {
async loadMetrics() {
return [];
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
const { orchestrator } = createSessionOrchestrator({
@@ -839,6 +860,10 @@ describe("turn metrics persistence", () => {
async loadMetrics() {
return [];
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
const { orchestrator } = createSessionOrchestrator({
@@ -1087,6 +1112,105 @@ describe("warm service", () => {
}
});
+ it("warm forwards a `warm`-flagged logger so the send is captured as a span", async () => {
+ const store = createInMemoryStore();
+ await store.append("conv-warm-log", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
+
+ let capturedOpts: ProviderStreamOptions | undefined;
+ const provider: ProviderContract = {
+ id: "p",
+ stream(_messages, _tools, opts) {
+ capturedOpts = opts;
+ return (async function* () {
+ yield {
+ type: "usage",
+ usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ } as ProviderEvent;
+ })();
+ },
+ };
+
+ // Minimal Logger stub recording the child() correlation it was asked for.
+ let childArg: (Partial<{ conversationId: string }> & { attrs?: unknown }) | undefined;
+ const warmChild = { __warmChild: true } as unknown as Logger;
+ const logger = {
+ debug() {},
+ info() {},
+ warn() {},
+ error() {},
+ span() {
+ throw new Error("warm should not open spans directly");
+ },
+ child(ctx: { conversationId?: string; attrs?: unknown }) {
+ childArg = ctx;
+ return warmChild;
+ },
+ } as unknown as Logger;
+
+ const deps = {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: () => {},
+ logger,
+ };
+ const { activeConversations } = createSessionOrchestrator(deps);
+ const warmService = createWarmService(deps, activeConversations);
+
+ await warmService.warm("conv-warm-log");
+
+ // The warm send must carry the logger so the provider opens a provider.request span.
+ expect(capturedOpts?.logger).toBe(warmChild);
+ // …and it must be flagged warm + correlated to the conversation, so it can be
+ // diffed against the real turn's request (the 0%-cache debugging workflow).
+ expect(childArg).toMatchObject({
+ conversationId: "conv-warm-log",
+ attrs: { warm: true },
+ });
+ });
+
+ it("warm falls back to the conversation's stored cwd for tool assembly", async () => {
+ // A cwd-sensitive tools filter (e.g. skill discovery) must see the SAME cwd
+ // the real turn used, or the tools block diverges and the prompt cache misses.
+ // A manual reheat sends no cwd, so the warm must fall back to the stored cwd.
+ const store = createInMemoryStore();
+ await store.append("conv-warm-cwd", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
+ await store.setCwd("conv-warm-cwd", "/home/tradam/projects/roblox");
+
+ let assemblyCwd: string | undefined = "UNSET";
+ const provider: ProviderContract = {
+ id: "p",
+ stream() {
+ return (async function* () {
+ yield {
+ type: "usage",
+ usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ } as ProviderEvent;
+ })();
+ },
+ };
+
+ const deps = {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: (assembly: ToolAssembly) => {
+ assemblyCwd = assembly.cwd;
+ return Promise.resolve(assembly);
+ },
+ runTurn,
+ emit: () => {},
+ };
+ const { activeConversations } = createSessionOrchestrator(deps);
+ const warmService = createWarmService(deps, activeConversations);
+
+ // No cwd in opts (the reheat case) → must use the stored cwd.
+ await warmService.warm("conv-warm-cwd");
+ expect(assemblyCwd).toBe("/home/tradam/projects/roblox");
+ });
+
it("warm refuses while the conversation is generating", async () => {
const store = createInMemoryStore();
let resolveRunTurn: (() => void) | undefined;
@@ -1324,3 +1448,123 @@ describe("warm service", () => {
expect(warmEmits).toHaveLength(0);
});
});
+
+describe("cwd persistence", () => {
+ it("uses the persisted cwd when the request omits cwd", async () => {
+ const store = createInMemoryStore();
+ await store.setCwd("conv-persisted", "/persisted/dir");
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-persisted",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/persisted/dir");
+ });
+
+ it("persists the cwd when the request provides one (and a later cwd-less turn reuses it)", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-persist-new",
+ text: "first",
+ onEvent: () => {},
+ cwd: "/new/dir",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/new/dir");
+ expect(store.cwdData.get("conv-persist-new")).toBe("/new/dir");
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-persist-new",
+ text: "second",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(2);
+ expect(captured[1]?.cwd).toBe("/new/dir");
+ });
+
+ it("an explicit request cwd overrides the persisted cwd (and updates it)", async () => {
+ const store = createInMemoryStore();
+ await store.setCwd("conv-override", "/old/dir");
+
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-override",
+ text: "override",
+ onEvent: () => {},
+ cwd: "/new/dir",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/new/dir");
+ expect(store.cwdData.get("conv-override")).toBe("/new/dir");
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-override",
+ text: "reused",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(2);
+ expect(captured[1]?.cwd).toBe("/new/dir");
+ });
+
+ it("no cwd is threaded when neither request nor store has one", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-cwd-either",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBeUndefined();
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 6df92c8..e86729c 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -117,15 +117,25 @@ export function createSessionOrchestrator(
const orchestrator: SessionOrchestrator = {
async handleMessage({ conversationId, text, onEvent, signal, modelName, cwd }) {
+ activeConversations.add(conversationId);
+
+ const effectiveCwd =
+ cwd !== undefined
+ ? cwd
+ : ((await deps.conversationStore.getCwd(conversationId)) ?? undefined);
+
const payload: TurnLifecyclePayload = {
conversationId,
- ...(cwd !== undefined ? { cwd } : {}),
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
...(modelName !== undefined ? { modelName } : {}),
};
deps.emit?.(turnStarted, payload);
- activeConversations.add(conversationId);
try {
+ if (cwd !== undefined) {
+ await deps.conversationStore.setCwd(conversationId, cwd);
+ }
+
const history = await deps.conversationStore.load(conversationId);
const userMsg = buildUserMessage(text);
const turnId = generateTurnId();
@@ -154,7 +164,7 @@ export function createSessionOrchestrator(
const assembled = await deps.applyToolsFilter({
tools: baseTools,
conversationId,
- ...(cwd !== undefined ? { cwd } : {}),
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
});
const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
const turnLogger = deps.logger?.child({ conversationId, turnId });
@@ -178,7 +188,7 @@ export function createSessionOrchestrator(
: {}),
...(turnLogger !== undefined ? { logger: turnLogger } : {}),
...(signal !== undefined ? { signal } : {}),
- ...(cwd !== undefined ? { cwd } : {}),
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
...(deps.now !== undefined ? { now: deps.now } : {}),
};
@@ -231,7 +241,14 @@ export function createWarmService(
}
const baseTools = deps.resolveTools();
- const cwd = opts?.cwd;
+ // Resolve cwd the SAME way handleMessage does (caller value → stored cwd).
+ // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
+ // `load_skill` description per-cwd). If the warm assembles tools under a
+ // different cwd than the real turn, the tools block — the FIRST bytes of
+ // the prompt-cache prefix — diverges and the cache misses entirely (0%).
+ // A manual reheat sends no cwd, so without this fallback it would warm the
+ // wrong prefix. See notes/observability-design.md §3.1.
+ const cwd = opts?.cwd ?? (await deps.conversationStore.getCwd(conversationId)) ?? undefined;
const assembled = await deps.applyToolsFilter({
tools: baseTools,
conversationId,
@@ -244,8 +261,18 @@ export function createWarmService(
};
const messages = [...history, probeMsg];
- const providerOpts: ProviderStreamOptions | undefined =
- modelOverride !== undefined ? { model: modelOverride, maxTokens: 1 } : { maxTokens: 1 };
+ // Capture the warm send as a `provider.request` span, flagged `warm: true`
+ // so it can be diffed against the corresponding real turn's request (the
+ // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md
+ // §3.1). Without this the warm body is invisible and the cache bust is
+ // undebuggable. The child-bound `warm` attribute flows into the span the
+ // provider opens (kernel logger merges child attrs into span attributes).
+ const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } });
+ const providerOpts: ProviderStreamOptions = {
+ maxTokens: 1,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(warmLogger !== undefined ? { logger: warmLogger } : {}),
+ };
let inputTokens = 0;
let outputTokens = 0;
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 9b6e9b3..7ebd2c2 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.4.0",
+ "version": "0.5.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts
new file mode 100644
index 0000000..da7e1e0
--- /dev/null
+++ b/packages/transport-contract/src/contract.types.test.ts
@@ -0,0 +1,118 @@
+/**
+ * Compile-time assertions for transport-contract types.
+ *
+ * These are never executed — they exist solely to prove the exported types
+ * compile with conforming literals. If a shape changes, this file fails to
+ * typecheck.
+ */
+
+import { describe, expect, it } from "vitest";
+import type {
+ CwdResponse,
+ LspServerInfo,
+ LspServerState,
+ LspStatusResponse,
+ SetCwdRequest,
+} from "./index.js";
+
+// ─── CwdResponse ─────────────────────────────────────────────────────────────
+
+const _cwdNull: CwdResponse = {
+ conversationId: "conv-1",
+ cwd: null,
+};
+
+const _cwdSet: CwdResponse = {
+ conversationId: "conv-2",
+ cwd: "/home/user/project",
+};
+
+// ─── SetCwdRequest ───────────────────────────────────────────────────────────
+
+const _setCwd: SetCwdRequest = {
+ cwd: "/tmp/workspace",
+};
+
+// ─── LspServerState ──────────────────────────────────────────────────────────
+
+const _stateConnected: LspServerState = "connected";
+const _stateStarting: LspServerState = "starting";
+const _stateError: LspServerState = "error";
+const _stateNotStarted: LspServerState = "not-started";
+
+// ─── LspServerInfo ───────────────────────────────────────────────────────────
+
+const _serverOk: LspServerInfo = {
+ id: "typescript",
+ name: "TypeScript Language Server",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected",
+};
+
+const _serverErr: LspServerInfo = {
+ id: "luau-lsp",
+ name: "Luau LSP",
+ root: "/home/user/game",
+ extensions: [".luau"],
+ state: "error",
+ error: "Failed to start: binary not found",
+};
+
+// ─── LspStatusResponse ───────────────────────────────────────────────────────
+
+const _lspNoCwd: LspStatusResponse = {
+ conversationId: "conv-3",
+ cwd: null,
+ servers: [],
+};
+
+const _lspWithServers: LspStatusResponse = {
+ conversationId: "conv-4",
+ cwd: "/home/user/project",
+ servers: [_serverOk, _serverErr],
+};
+
+// ─── Runtime smoke (vitest needs a suite) ────────────────────────────────────
+
+describe("transport-contract types compile and are exported", () => {
+ it("CwdResponse: null cwd round-trips", () => {
+ expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null });
+ });
+
+ it("CwdResponse: set cwd round-trips", () => {
+ expect(_cwdSet.cwd).toBe("/home/user/project");
+ });
+
+ it("SetCwdRequest: carries cwd", () => {
+ expect(_setCwd.cwd).toBe("/tmp/workspace");
+ });
+
+ it("LspServerState: all four variants are valid", () => {
+ const states: LspServerState[] = [
+ _stateConnected,
+ _stateStarting,
+ _stateError,
+ _stateNotStarted,
+ ];
+ expect(states).toHaveLength(4);
+ });
+
+ it("LspServerInfo: ok server has no error field", () => {
+ expect(_serverOk.state).toBe("connected");
+ expect(_serverOk.error).toBeUndefined();
+ });
+
+ it("LspServerInfo: error server carries error message", () => {
+ expect(_serverErr.state).toBe("error");
+ expect(_serverErr.error).toBe("Failed to start: binary not found");
+ });
+
+ it("LspStatusResponse: empty servers when cwd is null", () => {
+ expect(_lspNoCwd.servers).toEqual([]);
+ });
+
+ it("LspStatusResponse: populated servers when cwd is set", () => {
+ expect(_lspWithServers.servers).toHaveLength(2);
+ });
+});
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 95111ae..f0e50f8 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -154,6 +154,49 @@ export interface ThroughputResponse {
readonly models: readonly ThroughputModelStat[];
}
+// ─── Per-conversation working directory (cwd) ─────────────────────────────────
+
+/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
+export interface CwdResponse {
+ readonly conversationId: string;
+ readonly cwd: string | null;
+}
+
+/** Body of `PUT /conversations/:id/cwd`. */
+export interface SetCwdRequest {
+ readonly cwd: string;
+}
+
+// ─── Per-conversation LSP status ──────────────────────────────────────────────
+
+/** The connection state of a single language server for a workspace. */
+export type LspServerState = "connected" | "starting" | "error" | "not-started";
+
+/** One language server's status as reported to the frontend. */
+export interface LspServerInfo {
+ /** Stable server id, e.g. "typescript", "luau-lsp". */
+ readonly id: string;
+ /** Human-readable display name. */
+ readonly name: string;
+ /** The resolved workspace root the server is (or would be) rooted at (absolute). */
+ readonly root: string;
+ /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
+ readonly extensions: readonly string[];
+ /** Current connection state. */
+ readonly state: LspServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+}
+
+/** Response of `GET /conversations/:id/lsp`. */
+export interface LspStatusResponse {
+ readonly conversationId: string;
+ /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */
+ readonly cwd: string | null;
+ /** The language servers configured for `cwd` and their live state. */
+ readonly servers: readonly LspServerInfo[];
+}
+
/**
* Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
* request for a conversation (e.g. a frontend "warm now" button, or fast tests
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index d439fe4..2a3acdd 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -9,6 +9,7 @@
"@dispatch/conversation-store": "workspace:*",
"@dispatch/credential-store": "workspace:*",
"@dispatch/kernel": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 22b26fc..07f6777 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -13,6 +13,7 @@ import { createApp } from "./app.js";
import type {
ConversationStore,
CredentialStore,
+ LspService,
SessionOrchestrator,
WarmService,
} from "./seam.js";
@@ -78,6 +79,7 @@ function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[]
function createFakeConversationStore(
store: Map<string, StoredChunk[]> = new Map(),
metricsStore: Map<string, TurnMetrics[]> = new Map(),
+ cwdStore: Map<string, string> = new Map(),
): ConversationStore {
return {
async append() {},
@@ -93,6 +95,12 @@ function createFakeConversationStore(
async loadMetrics(conversationId) {
return metricsStore.get(conversationId) ?? [];
},
+ async getCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async setCwd(conversationId, cwd) {
+ cwdStore.set(conversationId, cwd);
+ },
};
}
@@ -169,6 +177,23 @@ function createFakeWarmService(
};
}
+function createFakeLspService(
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ }[] = [],
+): LspService {
+ return {
+ async status() {
+ return statuses;
+ },
+ };
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -752,6 +777,10 @@ describe("GET /conversations/:id/metrics", () => {
async loadMetrics() {
throw new Error("storage exploded");
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
const app = createApp({
conversationStore: brokenStore,
@@ -1031,3 +1060,154 @@ describe("throughput recording + GET /metrics/throughput", () => {
expect(res.status).toBe(400);
});
});
+
+describe("GET /conversations/:id/cwd", () => {
+ it("returns null when unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ });
+});
+
+describe("PUT then GET /conversations/:id/cwd", () => {
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.cwd).toBe("/home/user/project");
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBe("/home/user/project");
+ });
+});
+
+describe("PUT /conversations/:id/cwd", () => {
+ it("with missing cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("cwd");
+ });
+
+ it("with empty cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("GET /conversations/:id/lsp", () => {
+ it("returns empty servers when cwd is unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ });
+
+ it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lspStatuses = [
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ {
+ id: "lua-lsp",
+ name: "Lua LSP",
+ root: "/home/user/project",
+ extensions: [".luau"],
+ state: "error" as const,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(lspStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: string;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("typescript");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("lua-lsp");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 84c7d20..7778bad 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -2,6 +2,9 @@ import type { AgentEvent, Logger } from "@dispatch/kernel";
import type {
ConversationHistoryResponse,
ConversationMetricsResponse,
+ CwdResponse,
+ LspServerInfo,
+ LspStatusResponse,
ModelsResponse,
ThroughputResponse,
WarmResponse,
@@ -21,6 +24,8 @@ import {
import {
type ConversationStore,
type CredentialStore,
+ type LspServerStatus,
+ type LspService,
type SessionOrchestrator,
ThroughputQueryError,
type ThroughputStore,
@@ -32,6 +37,7 @@ export interface CreateServerOptions {
readonly orchestrator: SessionOrchestrator;
readonly credentialStore: CredentialStore;
readonly warmService?: WarmService;
+ readonly lspService?: LspService;
/** Optional — defaults to a no-op store (recording disabled, empty reports). */
readonly throughputStore?: ThroughputStore;
readonly logger?: Logger;
@@ -105,7 +111,7 @@ export function createApp(opts: CreateServerOptions): Hono {
"*",
cors({
origin: "*",
- allowMethods: ["GET", "POST", "OPTIONS"],
+ allowMethods: ["GET", "POST", "PUT", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
);
@@ -313,5 +319,87 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.get("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const cwd = await opts.conversationStore.getCwd(conversationId);
+ log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null });
+ const body: CwdResponse = { conversationId, cwd };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: cwd read failure", { err });
+ return c.json({ error: "Failed to read conversation cwd" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/cwd: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ if (typeof obj.cwd !== "string" || obj.cwd.length === 0) {
+ return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400);
+ }
+
+ try {
+ await opts.conversationStore.setCwd(conversationId, obj.cwd);
+ log.info("conversations: cwd set", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: obj.cwd };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: cwd set failure", { err });
+ return c.json({ error: "Failed to set conversation cwd" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/lsp", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const cwd = await opts.conversationStore.getCwd(conversationId);
+ if (cwd === null) {
+ log.info("conversations: lsp status read (no cwd)", { conversationId });
+ const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ if (opts.lspService === undefined) {
+ log.warn("conversations: lsp service not available", { conversationId });
+ return c.json({ error: "LSP service not available" }, 503);
+ }
+
+ const statuses = await opts.lspService.status(cwd);
+ const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => {
+ const info: LspServerInfo = {
+ id: s.id,
+ name: s.name,
+ root: s.root,
+ extensions: s.extensions,
+ state: s.state,
+ ...(s.error !== undefined ? { error: s.error } : {}),
+ };
+ return info;
+ });
+ log.info("conversations: lsp status read", {
+ conversationId,
+ cwd,
+ serverCount: servers.length,
+ });
+ const body: LspStatusResponse = { conversationId, cwd, servers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: lsp status failure", { err });
+ return c.json({ error: "Failed to read LSP status" }, 500);
+ }
+ });
+
return app;
}
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 5274033..6c988a5 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -4,6 +4,7 @@ import {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ lspServiceHandle,
sessionOrchestratorHandle,
throughputStoreHandle,
} from "./seam.js";
@@ -14,13 +15,21 @@ export const manifest: Manifest = {
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
- dependsOn: ["conversation-store", "credential-store", "session-orchestrator", "throughput-store"],
+ dependsOn: [
+ "conversation-store",
+ "credential-store",
+ "lsp",
+ "session-orchestrator",
+ "throughput-store",
+ ],
capabilities: { network: true },
contributes: {
routes: [
"/chat",
"/chat/warm",
"/conversations/:id",
+ "/conversations/:id/cwd",
+ "/conversations/:id/lsp",
"/health",
"/models",
"/metrics/throughput",
@@ -45,6 +54,7 @@ export function createTransportHttpExtension(): Extension & {
const credentialStore = host.getService(credentialStoreHandle);
const throughputStore = host.getService(throughputStoreHandle);
const warmService = host.getService(cacheWarmHandle);
+ const lspService = host.getService(lspServiceHandle);
const logger = host.logger;
const app = createApp({
@@ -53,6 +63,7 @@ export function createTransportHttpExtension(): Extension & {
credentialStore,
throughputStore,
warmService,
+ lspService,
logger,
});
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index 64929fc..718aaef 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -19,6 +19,7 @@ export {
export type {
ConversationStore,
CredentialStore,
+ LspService,
SessionOrchestrator,
WarmService,
} from "./seam.js";
@@ -26,5 +27,6 @@ export {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ lspServiceHandle,
sessionOrchestratorHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index e89370e..43c9d4d 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -2,6 +2,8 @@ export type { ConversationStore } from "@dispatch/conversation-store";
export { conversationStoreHandle } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
+export type { LspServerStatus, LspService } from "@dispatch/lsp";
+export { lspServiceHandle } from "@dispatch/lsp";
export type { SessionOrchestrator, WarmService } from "@dispatch/session-orchestrator";
export { cacheWarmHandle, sessionOrchestratorHandle } from "@dispatch/session-orchestrator";
export type { ThroughputStore } from "@dispatch/throughput-store";
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index 5b4c6aa..b43469f 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel";
import { createApp } from "./app.js";
import { createTransportHttpExtension } from "./index.js";
-import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js";
+import type {
+ ConversationStore,
+ CredentialStore,
+ LspService,
+ SessionOrchestrator,
+} from "./seam.js";
function fakeLogger(): Logger {
return {
@@ -41,6 +46,10 @@ function fakeConversationStore(): ConversationStore {
async loadMetrics() {
return [];
},
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
};
}
@@ -61,6 +70,14 @@ function fakeCredentialStore(): CredentialStore {
};
}
+function fakeLspService(): LspService {
+ return {
+ async status() {
+ return [];
+ },
+ };
+}
+
function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess {
return {
get<T>(key: string): T | undefined {
@@ -76,6 +93,7 @@ const SERVICES = new Map<string, unknown>([
["conversation-store/store", fakeConversationStore()],
["session-orchestrator/orchestrator", fakeOrchestrator()],
["credential-store/registry", fakeCredentialStore()],
+ ["lsp", fakeLspService()],
]);
function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI {
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index fc29c8c..fd3f3ea 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -6,6 +6,7 @@
{ "path": "../conversation-store" },
{ "path": "../credential-store" },
{ "path": "../kernel" },
+ { "path": "../lsp" },
{ "path": "../session-orchestrator" },
{ "path": "../throughput-store" },
{ "path": "../transport-contract" }