summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/extension.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
committerAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
commite7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch)
tree447095fd60b43980358d1565506f3ae2430e5f29 /packages/lsp/src/extension.ts
parent35937cee7f838e414eb8147c67205e01d85a4da0 (diff)
downloaddispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz
dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block โ€” the first bytes of the prompt-cache prefix โ€” diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs ยง3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/lsp/src/extension.ts')
-rw-r--r--packages/lsp/src/extension.ts120
1 files changed, 120 insertions, 0 deletions
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 };