diff --git a/README.md b/README.md index cd440bbab..d2e0e3fb9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Lume 运行在你自己的电脑上。记忆、对话、项目上下文、技能 - **持久记忆** — 三层作用域(global / workspace / thread)× 六种类型(fact / preference / decision / lesson / episode / milestone),新对话中自然召回;矛盾记忆并存,由你决定取舍,绝不静默覆盖。 - **角色团队** — 11 位有独立风格与专长的角色(开发者、作家、分析师、调研员、画师、设计师……),主线程理解任务后分发给最合适的人。 - **27+ Skills** — 每个 Skill 是一个 `SKILL.md` 提示词模板,支持热加载,修改即生效;配合自我进化机制,越用越准。 -- **完整工具集** — 文件系统(Read / Write / Edit / Glob / Grep)、Bash(超时 + 后台)、LSP 代码智能、Office 文档(docx / pptx / xlsx / pdf 创建编辑 + OOXML 修复)、Web 搜索与抓取、图片生成、标准 MCP 客户端。 +- **完整工具集** — 文件系统(Read / Write / Edit / Glob / Grep)、Bash(超时 + 后台)、Office 文档(docx / pptx / xlsx / pdf 创建编辑 + OOXML 修复)、Web 搜索与抓取、图片生成、标准 MCP 客户端。 - **自动化** — cron 定时任务、每日日程,到点自动执行并把结果推送到指定渠道。 - **IM 与阅读** — 微信接入(消息自动绑定工作区线程);微信读书书架/划线同步 + 两阶段智能笔记管线。 - **多模型** — OpenAI 兼容接口接入 OpenAI / Anthropic / Gemini / DeepSeek / GLM / 通义 / 豆包 / Moonshot 等,可按任务分配不同模型。 diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index de357e5ea..a6cb8265d 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -1,5 +1,4 @@ import { argv } from "node:process"; -import { shutdownLspClients } from "@lume/agent-sdk"; import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { startWorkspaceWatcher, stopWorkspaceWatcher } from "./services/system/workspace-watcher"; import { seedDefaultSkills } from "./services/skills/default-skills-seeder"; @@ -492,7 +491,6 @@ async function boot(): Promise { await Promise.allSettled([ getWorkspaceMcpManager().disposeAll(), stopAutomationRunner(), - shutdownLspClients(), externalChromeTransport?.close() ?? Promise.resolve(), ]); const { memoryJobService } = await import("./services/memory-v2/job-service"); diff --git a/apps/sidecar/src/services/agent-runtime/events/bus-bridge.ts b/apps/sidecar/src/services/agent-runtime/events/bus-bridge.ts index 4ab7b25c2..b2d4068fd 100644 --- a/apps/sidecar/src/services/agent-runtime/events/bus-bridge.ts +++ b/apps/sidecar/src/services/agent-runtime/events/bus-bridge.ts @@ -1,6 +1,6 @@ /** * ThreadEventBus 第二入口领域事件的统一发布口。 - * run 级领域事件(background.task / lsp.diagnostics / coding.report / + * run 级领域事件(background.task / coding.report / * advisor.reviewed / memory.context.used / todo.state)共用同一骨架: * turnId:null、ts:now、kind:"run"、phase:"event",失败仅 warn 不抛。 * 注意:lifecycle 投影 tee(run-loop 直通投影产物)与 run.end 终值补发 diff --git a/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.test.ts b/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.test.ts deleted file mode 100644 index 03bd29999..000000000 --- a/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { RegisteredPlugin } from "../plugins/plugin-registry.js"; -import { resolveRuntimeLspConfig } from "./lsp-config.js"; - -describe("runtime LSP config", () => { - test("merges user, reviewed plugin and project config in priority order", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-config-")); - const pluginRoot = join(root, "plugin"); - try { - await mkdir(pluginRoot); - await writeFile(join(pluginRoot, "lsp.yaml"), [ - "servers:", - " ts:", - " command: plugin-ts", - " plugin-only:", - " command: plugin-ls", - ].join("\n")); - await writeFile(join(root, "lsp.json"), JSON.stringify({ - diagnosticsOnWrite: false, - servers: { ts: { command: "project-ts" } }, - })); - const plugin = { - pluginId: "demo", - version: "1.0.0", - root: pluginRoot, - permissionState: { state: "loaded", reason: "approved" }, - permissions: { shell: { allow: true } }, - capabilities: { - skills: [], - commandTools: [], - lspServersConfigPath: "./lsp.yaml", - }, - } as unknown as RegisteredPlugin; - - const result = await resolveRuntimeLspConfig({ - cwd: root, - user: { - diagnosticsOnWrite: true, - servers: { ts: { command: "user-ts" }, user: { command: "user-ls" } }, - }, - plugins: [plugin], - }); - expect(result.diagnosticsOnWrite).toBe(false); - expect(result.servers?.ts?.command).toBe("project-ts"); - expect(result.servers?.["plugin-only"]?.command).toBe("plugin-ls"); - expect(result.servers?.user?.command).toBe("user-ls"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - test("ignores plugin LSP config without loaded shell permission", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-plugin-")); - try { - const projectRoot = join(root, "project"); - const pluginRoot = join(root, "plugin"); - await mkdir(projectRoot); - await mkdir(pluginRoot); - await writeFile(join(pluginRoot, "lsp.json"), JSON.stringify({ servers: { hidden: { command: "hidden-ls" } } })); - const plugin = { - pluginId: "demo", - version: "1.0.0", - root: pluginRoot, - permissionState: { state: "needs-review", reason: "changed" }, - permissions: { shell: { allow: true } }, - capabilities: { skills: [], commandTools: [], lspServersConfigPath: "./lsp.json" }, - } as unknown as RegisteredPlugin; - const result = await resolveRuntimeLspConfig({ cwd: projectRoot, plugins: [plugin] }); - expect(result.servers?.hidden).toBeUndefined(); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - test("records an invalid reviewed plugin LSP config without blocking other config", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-invalid-plugin-")); - try { - const projectRoot = join(root, "project"); - const pluginRoot = join(root, "plugin"); - await mkdir(projectRoot); - await mkdir(pluginRoot); - await writeFile(join(projectRoot, "lsp.json"), JSON.stringify({ - servers: { project: { command: "project-ls" } }, - })); - await writeFile(join(pluginRoot, "lsp.json"), "{ invalid"); - const plugin = { - pluginId: "demo", - version: "1.0.0", - root: pluginRoot, - permissionState: { state: "loaded", reason: "approved" }, - permissions: { shell: { allow: true } }, - capabilities: { skills: [], commandTools: [], lspServersConfigPath: "./lsp.json" }, - diagnostics: [], - } as unknown as RegisteredPlugin; - - const result = await resolveRuntimeLspConfig({ cwd: projectRoot, plugins: [plugin] }); - - expect(result.servers?.project?.command).toBe("project-ls"); - expect(plugin.diagnostics).toContainEqual(expect.objectContaining({ - code: "lsp_config_invalid", - path: join(pluginRoot, "lsp.json"), - })); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - test("merges .lume project defaults before direct project overrides", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-project-layers-")); - try { - await mkdir(join(root, ".lume")); - await writeFile(join(root, ".lume", "lsp.yaml"), [ - "diagnosticsOnWrite: true", - "servers:", - " inherited:", - " command: inherited-ls", - " ts:", - " command: lume-ts", - ].join("\n")); - await writeFile(join(root, "lsp.json"), JSON.stringify({ - diagnosticsOnWrite: false, - servers: { ts: { command: "direct-ts" } }, - })); - - const result = await resolveRuntimeLspConfig({ cwd: root, plugins: [] }); - - expect(result.diagnosticsOnWrite).toBe(false); - expect(result.servers?.ts?.command).toBe("direct-ts"); - expect(result.servers?.inherited?.command).toBe("inherited-ls"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - test("passes the lazy flag through so run startup can warm up servers", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-lazy-")); - const bare = await mkdtemp(join(tmpdir(), "lume-lsp-lazy-bare-")); - try { - await writeFile(join(root, "lsp.json"), JSON.stringify({ lazy: false })); - const result = await resolveRuntimeLspConfig({ cwd: root, plugins: [] }); - expect(result.lazy).toBe(false); - - const defaulted = await resolveRuntimeLspConfig({ - cwd: bare, - plugins: [], - }); - expect(defaulted.lazy).toBeUndefined(); - } finally { - await rm(root, { recursive: true, force: true }); - await rm(bare, { recursive: true, force: true }); - } - }); - - test("does not read configs above the containing git repository (#203)", async () => { - const root = await mkdtemp(join(tmpdir(), "lume-lsp-boundary-")); - const sub = join(root, "sub"); - try { - // No .git anywhere: only cwd is consulted, the ancestor config is ignored - await mkdir(sub, { recursive: true }); - await writeFile(join(root, "lsp.json"), JSON.stringify({ - servers: { ancestor: { command: "ancestor-ls" } }, - })); - - const noGit = await resolveRuntimeLspConfig({ cwd: sub, plugins: [] }); - expect(noGit.servers?.ancestor).toBeUndefined(); - - // With .git at root, the root config is reachable from a subdirectory, - // and configs above the git root stay invisible - await mkdir(join(root, ".git")); - const withGit = await resolveRuntimeLspConfig({ cwd: sub, plugins: [] }); - expect(withGit.servers?.ancestor?.command).toBe("ancestor-ls"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.ts b/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.ts deleted file mode 100644 index 9b7e94ed8..000000000 --- a/apps/sidecar/src/services/agent-runtime/lsp/lsp-config.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { parse as parseYaml } from "yaml"; -import type { LumeConfigLspSection, LumeLspServerConfig } from "@lume/shared"; -import type { RegisteredPlugin } from "../plugins/plugin-registry.js"; -import { getConfigDir } from "../../infra/config-paths.js"; - -export interface ResolvedRuntimeLspConfig extends LumeConfigLspSection { - servers?: Record; -} - -export async function resolveRuntimeLspConfig(input: { - cwd: string; - user?: LumeConfigLspSection; - plugins: RegisteredPlugin[]; -}): Promise { - const userFile = await readFirstConfig(getConfigDir(), [ - "lsp.json", - ".lsp.json", - "lsp.yaml", - ".lsp.yaml", - "lsp.yml", - ".lsp.yml", - ]); - const plugin = await loadPluginLspConfig(input.plugins); - const project = await loadProjectLspConfig(input.cwd); - return mergeLspSections(input.user, userFile, plugin, project); -} - -async function loadProjectLspConfig(cwd: string): Promise { - // Config lookup is bounded by the containing git repository; with no .git - // anywhere up the chain only cwd itself is consulted. Temp/shared ancestor - // directories must not be able to spawn servers (#203). - let boundary = resolve(cwd); - for (let dir = boundary; ; ) { - if (existsSync(join(dir, ".git"))) { - boundary = dir; - break; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - - let directory = resolve(cwd); - while (true) { - const direct = await readFirstConfig(directory, ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"]); - const lume = await readFirstConfig(join(directory, ".lume"), ["lsp.json", "lsp.yaml", "lsp.yml"]); - if (direct || lume) return mergeLspSections(lume, direct); - if (directory === boundary) return undefined; - directory = dirname(directory); - } -} - -async function loadPluginLspConfig(plugins: RegisteredPlugin[]): Promise { - let merged: LumeConfigLspSection | undefined; - for (const plugin of plugins) { - const path = plugin.capabilities.lspServersConfigPath; - if ( - !path - || plugin.permissionState?.state !== "loaded" - || plugin.permissions.shell?.allow !== true - ) continue; - const absolute = resolve(plugin.root, path); - if ( - isAbsolute(path) - || relative(plugin.root, absolute).startsWith("..") - ) { - addPluginDiagnostic(plugin, "unsafe_path", `LSP config path escapes the plugin root: ${path}`, absolute); - continue; - } - const parsed = await readPluginConfig(plugin, absolute); - if (parsed) merged = mergeLspSections(merged, parsed); - } - return merged; -} - -async function readFirstConfig(directory: string, names: string[]): Promise { - for (const name of names) { - const parsed = await readConfig(join(directory, name)); - if (parsed) return parsed; - } - return undefined; -} - -async function readConfig(path: string): Promise { - try { - const body = await readFile(path, "utf8"); - return parseLspConfig(path, body); - } catch { - return undefined; - } -} - -async function readPluginConfig( - plugin: RegisteredPlugin, - path: string, -): Promise { - try { - const [rootPath, configPath] = await Promise.all([realpath(plugin.root), realpath(path)]); - if (relative(rootPath, configPath).startsWith("..")) { - addPluginDiagnostic(plugin, "unsafe_path", "LSP config symlink escapes the plugin root.", path); - return undefined; - } - const body = await readFile(configPath, "utf8"); - const parsed = parseLspConfig(configPath, body); - if (!parsed) throw new Error("LSP config must contain an object of server definitions."); - return parsed; - } catch (error) { - addPluginDiagnostic( - plugin, - "lsp_config_invalid", - error instanceof Error ? error.message : String(error), - path, - ); - return undefined; - } -} - -function parseLspConfig(path: string, body: string): LumeConfigLspSection | undefined { - const parsed = /\.(yaml|yml)$/i.test(path) ? parseYaml(body) : JSON.parse(body); - return normalizeLspSection(parsed); -} - -function addPluginDiagnostic( - plugin: RegisteredPlugin, - code: "unsafe_path" | "lsp_config_invalid", - message: string, - path: string, -): void { - plugin.diagnostics.push({ - pluginId: plugin.pluginId, - version: plugin.version, - severity: "warning", - code, - message, - path, - }); -} - -function normalizeLspSection(value: unknown): LumeConfigLspSection | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - const record = value as Record; - const source = record.lsp && typeof record.lsp === "object" && !Array.isArray(record.lsp) - ? record.lsp as Record - : record; - const serversValue = source.servers && typeof source.servers === "object" && !Array.isArray(source.servers) - ? source.servers as Record - : looksLikeServerMap(source) ? source : undefined; - const servers = serversValue - ? Object.fromEntries(Object.entries(serversValue).filter(([, server]) => - Boolean(server) && typeof server === "object" && !Array.isArray(server) - )) as Record - : undefined; - return { - ...(typeof source.enabled === "boolean" ? { enabled: source.enabled } : {}), - ...(typeof source.lazy === "boolean" ? { lazy: source.lazy } : {}), - ...(typeof source.diagnosticsOnWrite === "boolean" ? { diagnosticsOnWrite: source.diagnosticsOnWrite } : {}), - ...(typeof source.diagnosticsDeduplicate === "boolean" ? { diagnosticsDeduplicate: source.diagnosticsDeduplicate } : {}), - ...(typeof source.formatOnWrite === "boolean" ? { formatOnWrite: source.formatOnWrite } : {}), - ...(typeof source.idleTimeoutMs === "number" ? { idleTimeoutMs: source.idleTimeoutMs } : {}), - ...(source.useLspmux === "auto" || source.useLspmux === "off" ? { useLspmux: source.useLspmux } : {}), - ...(servers ? { servers } : {}), - }; -} - -function looksLikeServerMap(value: Record): boolean { - const optionKeys = new Set([ - "enabled", - "lazy", - "diagnosticsOnWrite", - "diagnosticsDeduplicate", - "formatOnWrite", - "idleTimeoutMs", - "useLspmux", - ]); - return Object.keys(value).some((key) => !optionKeys.has(key)); -} - -function mergeLspSections(...sections: Array): ResolvedRuntimeLspConfig { - const output: ResolvedRuntimeLspConfig = {}; - for (const section of sections) { - if (!section) continue; - Object.assign(output, section, { - servers: { - ...(output.servers ?? {}), - ...(section.servers ?? {}), - }, - }); - } - if (Object.keys(output.servers ?? {}).length === 0) delete output.servers; - return output; -} diff --git a/apps/sidecar/src/services/agent-runtime/runner/run-item-events.test.ts b/apps/sidecar/src/services/agent-runtime/runner/run-item-events.test.ts index 708287cc9..58afdd150 100644 --- a/apps/sidecar/src/services/agent-runtime/runner/run-item-events.test.ts +++ b/apps/sidecar/src/services/agent-runtime/runner/run-item-events.test.ts @@ -80,40 +80,6 @@ describe("projectRunStateToRuntimeEvents", () => { expect(events).toEqual([]); }); - test("projects delayed LSP diagnostics without a chat status item", () => { - const events = projectRunItemToRuntimeEvents(baseRun(), { - type: "system_event", - id: "lsp-1", - name: "lsp_diagnostics", - payload: { - tool_use_id: "edit-1", - file_path: "src/index.ts", - mutation_version: 2, - sha256: "abc", - delayed: true, - diagnostics: { - servers: ["typescript-language-server"], - total: 1, - errors: 1, - warnings: 0, - truncated: false, - items: [] - } - }, - createdAt: "2026-04-30T00:00:02.000Z" - }, { - includeAssistantText: true, - includeAssistantThinking: true, - includeModelStreamText: true - }); - expect(events).toEqual([expect.objectContaining({ - type: "lsp.diagnostics.updated", - toolUseId: "edit-1", - mutationVersion: 2, - delayed: true - })]); - }); - test("projects kernel run facts into product runtime events", () => { const run = baseRun({ runId: "run-runtime-1", diff --git a/apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts b/apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts index 038781691..2cfa5c86b 100644 --- a/apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts +++ b/apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts @@ -451,35 +451,6 @@ function projectSystemEventRuntimeEvents(run: LumeRunState, item: LumeRunItem): const event = projectBackgroundTaskNotificationRuntimeEvent(run.threadId, payload, item.createdAt); return event ? [event] : []; } - if (item.name === "lsp_diagnostics") { - const diagnostics = asRecord(payload.diagnostics); - const filePath = stringValue(payload.file_path, ""); - const sha256 = stringValue(payload.sha256, ""); - if (!filePath || !sha256) return []; - return [{ - id: `${run.runId}:${item.id}:lsp.diagnostics.updated`, - type: "lsp.diagnostics.updated", - threadId: run.threadId, - runId: run.runId, - createdAt: item.createdAt, - ...(typeof payload.tool_use_id === "string" ? { toolUseId: payload.tool_use_id } : {}), - filePath, - mutationVersion: numberValue(payload.mutation_version), - sha256, - delayed: payload.delayed === true, - diagnostics: { - servers: Array.isArray(diagnostics.servers) ? diagnostics.servers.filter((value): value is string => typeof value === "string") : [], - total: numberValue(diagnostics.total), - errors: numberValue(diagnostics.errors), - warnings: numberValue(diagnostics.warnings), - truncated: diagnostics.truncated === true, - items: Array.isArray(diagnostics.items) ? diagnostics.items as Extract["diagnostics"]["items"] : [], - ...(diagnostics.artifact && typeof diagnostics.artifact === "object" - ? { artifact: diagnostics.artifact as Extract["diagnostics"]["artifact"] } - : {}) - } - }]; - } if (item.name === "context_compaction_started") { return [{ id: `${run.runId}:${item.id}:context.compaction.started`, diff --git a/apps/sidecar/src/services/agent-runtime/runner/run-observer.ts b/apps/sidecar/src/services/agent-runtime/runner/run-observer.ts index 571f8f48b..fe009403f 100644 --- a/apps/sidecar/src/services/agent-runtime/runner/run-observer.ts +++ b/apps/sidecar/src/services/agent-runtime/runner/run-observer.ts @@ -281,7 +281,7 @@ export class LumeRunObserver { item.createdAt ); } - // T7a:已迁类(assistant/tool/todo/advisor/lsp/compaction 等)的 live 投影删除, + // T7a:已迁类(assistant/tool/todo/advisor/compaction 等)的 live 投影删除, // live 由事件总线经适配器驱动;唯一保留的 item 级 live 投影是裁定保留类 // usage.updated(system_event name=result)。item 记录照常落盘(run state/trace 消费)。 if (item.type === "system_event" && item.name === "result") { diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.test.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.test.ts index 59888e121..1e44e25fa 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.test.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.test.ts @@ -335,37 +335,6 @@ describe("coding run tracker", () => { expect(tracker.getVerificationReport().pendingBackground).toBe(false); }); - test("persists delayed LSP diagnostics in the Coding report without changing verification", () => { - const workspaceRoot = join(tmpdir(), "lume-lsp-report"); - const tracker = createCodingRunTracker({ workspaceRoot }); - expect(tracker.observeAsyncEvent({ - type: "system", - subtype: "lsp_diagnostics", - file_path: join(workspaceRoot, "src", "index.ts"), - mutation_version: 2, - sha256: "abc", - delayed: true, - diagnostics: { - servers: ["typescript-language-server"], - total: 2, - errors: 1, - warnings: 1, - truncated: false, - items: [], - }, - })).toBe(true); - - expect(tracker.getVerificationReport()).toMatchObject({ - status: "not_required", - lspDiagnostics: { - files: ["src/index.ts"], - total: 2, - errors: 1, - warnings: 1, - }, - }); - }); - test("records a failed auto-backgrounded verification without aborting the finished turn", async () => { const tracker = createCodingRunTracker(); tracker.observe({ toolName: "Edit", input: { file_path: "a.ts" }, result: result("edited") }); diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.ts index 794b7424b..fcddc1a38 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-run-tracker.ts @@ -46,13 +46,6 @@ export interface CodingVerificationReport { verificationNoEvidenceAttempts?: number; verificationRecords?: CodingVerificationRecord[]; recommendedVerificationCommands?: string[]; - lspDiagnostics?: { - files: string[]; - total: number; - errors: number; - warnings: number; - updatedAt: string; - }; gitActions?: CodingGitAction[]; approvalRequestCount?: number; turnId?: string; @@ -82,12 +75,6 @@ interface PersistedCodingRunState { verificationNoEvidenceAttempts?: number; fileChangeStats?: Record; verificationRecords?: CodingVerificationRecord[]; - lspDiagnostics?: Record; gitActions?: CodingGitAction[]; } @@ -140,12 +127,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { let disposed = false; let successfulShellObserved = false; const verificationRecords: CodingVerificationRecord[] = []; - const lspDiagnostics = new Map(); const gitActions: CodingGitAction[] = []; let recommendedVerificationCommands: string[] = []; @@ -196,7 +177,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { verificationNoEvidenceAttempts, fileChangeStats: Object.fromEntries(fileChangeStats), verificationRecords, - lspDiagnostics: Object.fromEntries(lspDiagnostics), gitActions }; } @@ -274,10 +254,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { } } verificationRecords.splice(0, verificationRecords.length, ...(state.verificationRecords ?? []).slice(-8)); - for (const [path, diagnostics] of Object.entries(state.lspDiagnostics ?? {})) { - if (!diagnostics || typeof diagnostics !== "object") continue; - lspDiagnostics.set(path, diagnostics); - } gitActions.splice(0, gitActions.length, ...(state.gitActions ?? []).slice(-16)); } catch { // Missing, corrupt, partial, or oversized legacy state establishes a fresh baseline. @@ -303,7 +279,7 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { const toolStillRunning = task?.status ? task.status === "running" : execution?.terminationReason === "running"; - if (["bash", "write", "edit", "notebookedit", "lsp"].includes(name)) { + if (["bash", "write", "edit", "notebookedit"].includes(name)) { workspaceMonitor.finishTool(name, task?.id, name === "bash" && toolStillRunning); } if (isProcessOutput && !toolStillRunning) { @@ -351,7 +327,7 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { } } const bashMutation = name === "bash" && isLikelyMutationCommand(input); - if ((["write", "edit", "notebookedit", "lsp"].includes(name) || bashMutation) && input.result.is_error !== true) { + if ((["write", "edit", "notebookedit"].includes(name) || bashMutation) && input.result.is_error !== true) { mutationObserved = true; if (name !== "bash") { const path = readMutationPath(input.input, input.result, options.workspaceRoot, workspaceRoots); @@ -504,16 +480,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { } function observeAsyncEvent(message: SDKMessage): boolean { - if (message.type === "system" && message.subtype === "lsp_diagnostics") { - lspDiagnostics.set(message.file_path, { - total: message.diagnostics.total, - errors: message.diagnostics.errors, - warnings: message.diagnostics.warnings, - updatedAt: new Date().toISOString() - }); - persist(); - return true; - } if (message.type !== "system" || message.subtype !== "task_notification" || !message.execution) { return false; } @@ -570,7 +536,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { ?? fileChanges.reduce((sum, change) => sum + (change.addedLines ?? 0), 0); const totalRemovedLines = authoritativeChangeSet?.totalRemovedLines ?? fileChanges.reduce((sum, change) => sum + (change.removedLines ?? 0), 0); - const lspEntries = [...lspDiagnostics.entries()]; return { phase: getCodingTurnPhase(), status: verificationStatus, @@ -587,17 +552,6 @@ export function createCodingRunTracker(options: CodingRunTrackerOptions = {}) { verificationRepairAttempts, verificationNoEvidenceAttempts, verificationRecords: [...verificationRecords], - ...(lspEntries.length > 0 ? { - lspDiagnostics: { - files: lspEntries.map(([path]) => displayWorkspacePath(path, options.workspaceRoot)), - total: lspEntries.reduce((sum, [, diagnostics]) => sum + diagnostics.total, 0), - errors: lspEntries.reduce((sum, [, diagnostics]) => sum + diagnostics.errors, 0), - warnings: lspEntries.reduce((sum, [, diagnostics]) => sum + diagnostics.warnings, 0), - updatedAt: lspEntries.reduce((latest, [, diagnostics]) => - diagnostics.updatedAt > latest ? diagnostics.updatedAt : latest, "" - ) - } - } : {}), ...(recommendedVerificationCommands.length > 0 ? { recommendedVerificationCommands } : {}), gitActions: [...gitActions], ...(options.turnId ? { turnId: options.turnId } : {}), diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-workspace-monitor.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-workspace-monitor.ts index 660b181bd..7f9f56e3f 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/coding-workspace-monitor.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/coding-workspace-monitor.ts @@ -8,7 +8,7 @@ const SETTLE_TIMEOUT_MS = 400; const WATCHER_READY_TIMEOUT_MS = 10_000; const MAX_CANDIDATE_PATHS = 10_000; -const MUTATION_WINDOW_TOOLS = new Set(["bash", "write", "edit", "notebookedit", "lsp"]); +const MUTATION_WINDOW_TOOLS = new Set(["bash", "write", "edit", "notebookedit"]); export type CodingWorkspaceMonitorReadiness = "ready" | "degraded"; const ISOLATED_WATCHER_SOURCE = String.raw` diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/run-background.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/run-background.ts index d82e71536..ffa95b038 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/run-background.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/run-background.ts @@ -1,7 +1,7 @@ /** * runtime-core 后台任务收尾与总线第二入口发布(#177 自 run.ts 拆出,纯移动): * process job 等待/终态判定、background.task 结果上下文构造、 - * background.task / lsp.diagnostics / coding.report 领域事件发布。 + * background.task / coding.report 领域事件发布。 */ import { waitForProcessJobTerminal, @@ -13,7 +13,6 @@ import type { RuntimeCodingReport, BackgroundTaskNotificationDetail, CodingReportDetail, - LspDiagnosticsDetail, } from "@lume/shared"; import { normalizeBackgroundTaskStatus } from "@lume/shared"; import { join } from "node:path"; @@ -111,39 +110,6 @@ export function publishBackgroundTaskNotificationToBus(input: { publishRunDomainEvent({ ...input, label: "background.task", detail }); } -/** - * 批次5 第二入口:lsp_diagnostics(system 异步事件)在旧路(coding-run-tracker 观察 + - * run-item-events 投影 lsp.diagnostics.updated RuntimeEvent)之外,flag on 时经 - * ThreadEventBus 再发一份 lsp.diagnostics 领域事件——字段与旧路逐一对齐 - * (toolUseId←tool_use_id、filePath←file_path 等);filePath/sha256 缺失丢弃, - * 同旧路 gate(run-item-events lsp_diagnostics 分支)。T7c 起恒开(批次1 flag 已退役)。 - */ -export function publishLspDiagnosticsToBus(input: { - sessionDir: string; - threadId: string; - runId: string; - event: SDKMessage; -}): void { - // SDKLspDiagnosticsMessage 未从 @lume/agent-sdk index 导出,与 engine 同法经 Extract 取型 - const message = input.event as Extract< - SDKMessage, - { type: "system"; subtype: "lsp_diagnostics" } - >; - if (!message.file_path || !message.sha256) return; - const detail: LspDiagnosticsDetail = { - type: "lsp.diagnostics", - filePath: message.file_path, - mutationVersion: message.mutation_version, - sha256: message.sha256, - delayed: message.delayed === true, - diagnostics: message.diagnostics, - ...(typeof message.tool_use_id === "string" - ? { toolUseId: message.tool_use_id } - : {}), - }; - publishRunDomainEvent({ ...input, label: "lsp.diagnostics", detail }); -} - /** * 批次5 第二入口:coding.report.updated 的产生点(publishCodingReport)在旧路 * RuntimeEvent 之外,经 ThreadEventBus 再发一份 coding.report 领域事件—— diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/run-tools.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/run-tools.ts index b25998802..87e001363 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/run-tools.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/run-tools.ts @@ -28,9 +28,7 @@ import { FileWriteTool, GlobTool, GrepTool, - LSPTool, NotebookEditTool, - LSPApplyTool, ProcessOutputTool, ProcessStopTool, EnterWorktreeTool, @@ -191,8 +189,6 @@ function createBaseSdkAlignedTools( ProcessStopTool, NotebookEditTool, SkillTool, - LSPTool, - LSPApplyTool, EnterWorktreeTool, ExitWorktreeTool, ]; diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/run.second-entries-bus.test.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/run.second-entries-bus.test.ts index dfe9a5564..8074ea72b 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/run.second-entries-bus.test.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/run.second-entries-bus.test.ts @@ -5,33 +5,7 @@ import { join } from "node:path"; import type { RuntimeCodingReport, SdkEventEnvelope } from "@lume/shared"; import { getThreadEventBus } from "../events/thread-event-bus"; import { getRuntimeCoreSessionDir } from "./session-store"; -import { publishCodingReportToBus, publishLspDiagnosticsToBus } from "./run-background"; - -function lspDiagnosticsMessage(fields: Record = {}) { - return { - type: "system", - subtype: "lsp_diagnostics", - tool_use_id: "tu-1", - file_path: "src/a.ts", - mutation_version: 3, - sha256: "abc123", - delayed: true, - diagnostics: { - servers: ["tsserver"], - total: 2, - errors: 1, - warnings: 1, - truncated: false, - items: [{ - server: "tsserver", - severity: 1, - message: "oops", - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } } - }] - }, - ...fields - }; -} +import { publishCodingReportToBus } from "./run-background"; const codingReport = { status: "unverified", @@ -41,86 +15,10 @@ const codingReport = { runId: "lume-run-1" } as unknown as RuntimeCodingReport; -function isLspDiagnosticsDetail(detail: unknown): boolean { - return (detail as { type?: string } | null)?.type === "lsp.diagnostics"; -} - function isCodingReportDetail(detail: unknown): boolean { return (detail as { type?: string } | null)?.type === "coding.report"; } -describe("批次5 第二入口:lsp.diagnostics(handleAsyncEvent 旁路 helper)", () => { - const dirs: string[] = []; - - afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - }); - - function setup(threadId: string): { sessionDir: string; published: SdkEventEnvelope[] } { - const agentDir = mkdtempSync(join(tmpdir(), "run-lsp-bus-")); - dirs.push(agentDir); - const sessionDir = getRuntimeCoreSessionDir(threadId, agentDir); - const published: SdkEventEnvelope[] = []; - getThreadEventBus(sessionDir).subscribe(threadId, (envelope) => { - if (isLspDiagnosticsDetail(envelope.detail)) published.push(envelope); - }); - return { sessionDir, published }; - } - - test("字段与旧路 lsp.diagnostics.updated 逐一对齐", async () => { - const threadId = "run-lsp-bus-on"; - const { sessionDir, published } = setup(threadId); - - publishLspDiagnosticsToBus({ - sessionDir, - threadId, - runId: "lume-run-1", - event: lspDiagnosticsMessage() as never - }); - - expect(published).toHaveLength(1); - const envelope = published[0]!; - expect(envelope.kind).toBe("run"); - expect(envelope.phase).toBe("event"); - expect(envelope.turnId).toBeNull(); - expect(envelope.threadId).toBe(threadId); - expect(envelope.runId).toBe("lume-run-1"); - expect(envelope.detail).toEqual({ - type: "lsp.diagnostics", - toolUseId: "tu-1", - filePath: "src/a.ts", - mutationVersion: 3, - sha256: "abc123", - delayed: true, - diagnostics: lspDiagnosticsMessage().diagnostics - }); - - expect(await getThreadEventBus(sessionDir).read(threadId)) - .toContainEqual(expect.objectContaining({ - kind: "run", - phase: "event", - detail: expect.objectContaining({ type: "lsp.diagnostics", filePath: "src/a.ts" }) - })); - }); - - test("filePath/sha256 缺失丢弃(同旧路 run-item-events gate)", async () => { - const threadId = "run-lsp-bus-gate"; - const { sessionDir, published } = setup(threadId); - - publishLspDiagnosticsToBus({ - sessionDir, - threadId, - runId: "lume-run-1", - event: lspDiagnosticsMessage({ sha256: "" }) as never - }); - - expect(published).toHaveLength(0); - expect(await getThreadEventBus(sessionDir).read(threadId)).toEqual([]); - }); -}); - describe("批次5 第二入口:coding.report(publishCodingReport 产生点双发 helper)", () => { const dirs: string[] = []; diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/run.test.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/run.test.ts index fec8919cf..5b691cef6 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/run.test.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/run.test.ts @@ -545,7 +545,6 @@ describe("runtime-core run", () => { expect(toolNames).toContain("TaskStop"); expect(toolNames).toContain("ProcessOutput"); expect(toolNames).toContain("ProcessStop"); - expect(toolNames).toContain("LSPApply"); expect(toolNames).not.toContain("TaskReport"); expect(toolNames).not.toContain("read"); expect(toolNames).not.toContain("write"); @@ -576,7 +575,7 @@ describe("runtime-core run", () => { }); const toolNames = availableToolNames(result); - for (const toolName of ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "NotebookEdit", "LSP", "LSPApply", "ProcessOutput", "ProcessStop"]) { + for (const toolName of ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "NotebookEdit", "ProcessOutput", "ProcessStop"]) { expect(toolNames).toContain(toolName); } for (const toolName of ["WebSearch", "WebFetch", "Agent", "TaskCreate", "TaskUpdate", "TaskList", "TaskGet"]) { diff --git a/apps/sidecar/src/services/agent-runtime/runtime-core/run.ts b/apps/sidecar/src/services/agent-runtime/runtime-core/run.ts index 48f62b812..af2a47956 100644 --- a/apps/sidecar/src/services/agent-runtime/runtime-core/run.ts +++ b/apps/sidecar/src/services/agent-runtime/runtime-core/run.ts @@ -13,8 +13,6 @@ import { type ToolDefinition, type PersistedToolContinuation, type NormalizedMessageParam, - setLspIdleTimeout, - warmupLspClients, detectDanglingToolUses, buildResumeContinuations, } from "@lume/agent-sdk"; @@ -85,7 +83,6 @@ import { FilePluginStateStore, } from "../plugins/plugin-state-store.js"; import { buildPluginAgentHooks } from "../plugins/plugin-hooks-bridge.js"; -import { resolveRuntimeLspConfig } from "../lsp/lsp-config.js"; import { buildPluginMcpManager, buildPluginIdIndex, @@ -140,7 +137,6 @@ import { isTerminalProcessJob, publishBackgroundTaskNotificationToBus, publishCodingReportToBus, - publishLspDiagnosticsToBus, waitForProcessJobToFinish, type BackgroundTaskResult, } from "./run-background"; @@ -542,15 +538,6 @@ async function createRuntimeCoreSessionImpl( event, }); } - // 批次5 第二入口:lsp_diagnostics 同批再上总线(与 task_notification 同构旁路) - if (event.type === "system" && event.subtype === "lsp_diagnostics") { - publishLspDiagnosticsToBus({ - sessionDir, - threadId: input.lumeSessionId, - runId, - event, - }); - } try { input.emitSdkMessage?.(event); } catch (error) { @@ -631,40 +618,6 @@ async function createRuntimeCoreSessionImpl( // Do not auto-load project-local .lume/plugins just because the Agent cwd is a real project. directories: pluginConfig.directories, }); - const discoveredLspConfig = await resolveRuntimeLspConfig({ - cwd: input.cwd, - user: getEffectiveLumeConfig(input.workspaceSlug).lsp, - plugins: registeredPlugins, - }); - const runLspConfig = - input.toolConfig?.lsp && - typeof input.toolConfig.lsp === "object" && - !Array.isArray(input.toolConfig.lsp) - ? (input.toolConfig.lsp as Record) - : undefined; - const lspConfig = { - ...discoveredLspConfig, - ...(runLspConfig ?? {}), - ...(discoveredLspConfig.servers || - (runLspConfig?.servers && typeof runLspConfig.servers === "object") - ? { - servers: { - ...(discoveredLspConfig.servers ?? {}), - ...(runLspConfig?.servers && - typeof runLspConfig.servers === "object" - ? (runLspConfig.servers as Record) - : {}), - }, - } - : {}), - }; - setLspIdleTimeout(lspConfig.idleTimeoutMs); - // 默认保持懒启动;lazy: false 时在 run 启动阶段后台预热 rootMarkers 匹配的 - // server(warmupLspClients 内置 5s race,慢环境自动放行),首个 Write/Edit - // 不再付 language server 冷启动代价。fire-and-forget,不阻塞首事件。 - if (lspConfig.enabled !== false && lspConfig.lazy === false) { - void warmupLspClients(input.cwd, { lsp: lspConfig }).catch(() => undefined); - } const computerUsePlugin = registeredPlugins.find( (plugin) => plugin.pluginId === "computer-use", ); @@ -1108,8 +1061,7 @@ async function createRuntimeCoreSessionImpl( ? [persistedContinuation] : danglingFallbackContinuations; const runtimeToolConfig = { - ...(input.toolConfig ?? {}), - ...(Object.keys(lspConfig).length > 0 ? { lsp: lspConfig } : {}), + ...input.toolConfig, }; const apiType = diff --git a/apps/sidecar/src/services/agent-runtime/tools/tool-metadata.ts b/apps/sidecar/src/services/agent-runtime/tools/tool-metadata.ts index d90264eeb..0725462cd 100644 --- a/apps/sidecar/src/services/agent-runtime/tools/tool-metadata.ts +++ b/apps/sidecar/src/services/agent-runtime/tools/tool-metadata.ts @@ -616,14 +616,6 @@ registerToolMetadata({ allowedInPlanMode: false }); -// LSP 工具 -registerToolMetadata({ - name: "LSP", - category: "read", - riskLevel: "low", - description: "LSP 代码智能" -}); - // Task 工具(启动子 Agent) registerToolMetadata({ name: "Task", diff --git a/apps/sidecar/src/services/agent-runtime/tools/tool-runtime-wrapper.ts b/apps/sidecar/src/services/agent-runtime/tools/tool-runtime-wrapper.ts index 2862f07b9..afb8f4ff1 100644 --- a/apps/sidecar/src/services/agent-runtime/tools/tool-runtime-wrapper.ts +++ b/apps/sidecar/src/services/agent-runtime/tools/tool-runtime-wrapper.ts @@ -358,8 +358,7 @@ function isMutationTool(canonicalName: string): boolean { return canonicalName === "write" || canonicalName === "edit" || canonicalName === "notebookedit" - || canonicalName === "bash" - || canonicalName === "lsp"; + || canonicalName === "bash"; } function readInputPath(input: unknown): string | undefined { diff --git a/apps/sidecar/src/services/agent/agent-service.ts b/apps/sidecar/src/services/agent/agent-service.ts index ba8319437..a28df79f9 100644 --- a/apps/sidecar/src/services/agent/agent-service.ts +++ b/apps/sidecar/src/services/agent/agent-service.ts @@ -564,7 +564,6 @@ function shouldPersistAssistantTurnSdkMessage(message: SDKMessage): boolean { || message.subtype === "task_started" || message.subtype === "task_progress" || message.subtype === "task_notification" - || message.subtype === "lsp_diagnostics" ); } diff --git a/apps/sidecar/src/services/agent/agent-thread-manager.ts b/apps/sidecar/src/services/agent/agent-thread-manager.ts index 0b4507c23..34527ee6b 100644 --- a/apps/sidecar/src/services/agent/agent-thread-manager.ts +++ b/apps/sidecar/src/services/agent/agent-thread-manager.ts @@ -50,7 +50,6 @@ import { } from "../agent-runtime/runtime-core/session-store"; import { getEffectiveLumeConfig } from "../system/lume-config-service"; import { createLogger } from "../infra/logger"; -import { clearLspWritethroughState } from "@lume/agent-sdk"; interface AgentThreadsIndex { version: number; @@ -696,8 +695,6 @@ function deleteAgentThreadLocked(id: string): void { } getAgentSubmissionStore().deleteThread(id); - // sidecar 删会话不经 sdk deleteSession,LSP 写透状态需在此一并回收(防常驻进程 Map 膨胀)。 - clearLspWritethroughState(id); if (cleanupPending) { planningStore.advanceOperation(operationId, { phase: "cleanup_pending", status: "partial", recoverable: true, threadId: id, error: "thread file cleanup pending" }); } else { diff --git a/apps/sidecar/src/services/system/lume-config-service.ts b/apps/sidecar/src/services/system/lume-config-service.ts index 66d42c005..a6c44c326 100644 --- a/apps/sidecar/src/services/system/lume-config-service.ts +++ b/apps/sidecar/src/services/system/lume-config-service.ts @@ -89,14 +89,6 @@ function createDefaultLumeConfig(): LumeConfigFile { internal: { ...DEFAULT_INTERNAL_HOOKS } }, webSearch: { ...DEFAULT_LUME_WEB_SEARCH }, - lsp: { - enabled: true, - diagnosticsOnWrite: true, - diagnosticsDeduplicate: true, - formatOnWrite: false, - idleTimeoutMs: 10 * 60_000, - useLspmux: "auto" - }, workspaces: {} }; } @@ -655,39 +647,6 @@ function normalizeSectionSet(value: unknown): LumeConfigSectionSet { if (isPlainObject(value.webSearch)) { next.webSearch = normalizeWebSearchSection(value.webSearch); } - if (isPlainObject(value.lsp)) { - const servers = isPlainObject(value.lsp.servers) - ? Object.fromEntries(Object.entries(value.lsp.servers).flatMap(([name, server]) => { - if (!name.trim() || !isPlainObject(server)) return []; - return [[name, { - ...(typeof server.disabled === "boolean" ? { disabled: server.disabled } : {}), - ...(typeof server.command === "string" ? { command: server.command } : {}), - ...(Array.isArray(server.args) ? { args: normalizeStringArray(server.args) } : {}), - ...(typeof server.cwd === "string" ? { cwd: server.cwd } : {}), - ...(Array.isArray(server.fileTypes) ? { fileTypes: normalizeStringArray(server.fileTypes) } : {}), - ...(Array.isArray(server.rootMarkers) ? { rootMarkers: normalizeStringArray(server.rootMarkers) } : {}), - ...(isPlainObject(server.initOptions) ? { initOptions: server.initOptions } : {}), - ...(isPlainObject(server.settings) ? { settings: server.settings } : {}), - ...(typeof server.requestTimeoutMs === "number" ? { requestTimeoutMs: server.requestTimeoutMs } : {}), - ...(typeof server.warmupTimeoutMs === "number" ? { warmupTimeoutMs: server.warmupTimeoutMs } : {}), - ...(typeof server.priority === "number" ? { priority: server.priority } : {}), - ...(server.role === "primary" || server.role === "linter" - ? { role: server.role as "primary" | "linter" } - : {}) - }]]; - })) - : undefined; - next.lsp = { - ...(typeof value.lsp.enabled === "boolean" ? { enabled: value.lsp.enabled } : {}), - ...(typeof value.lsp.lazy === "boolean" ? { lazy: value.lsp.lazy } : {}), - ...(typeof value.lsp.diagnosticsOnWrite === "boolean" ? { diagnosticsOnWrite: value.lsp.diagnosticsOnWrite } : {}), - ...(typeof value.lsp.diagnosticsDeduplicate === "boolean" ? { diagnosticsDeduplicate: value.lsp.diagnosticsDeduplicate } : {}), - ...(typeof value.lsp.formatOnWrite === "boolean" ? { formatOnWrite: value.lsp.formatOnWrite } : {}), - ...(typeof value.lsp.idleTimeoutMs === "number" ? { idleTimeoutMs: value.lsp.idleTimeoutMs } : {}), - ...(value.lsp.useLspmux === "auto" || value.lsp.useLspmux === "off" ? { useLspmux: value.lsp.useLspmux } : {}), - ...(servers ? { servers } : {}) - }; - } return next; } @@ -831,14 +790,6 @@ function normalizeLumeConfigFile(input: unknown): LumeConfigFile { ...(DEFAULT_LUME_WEB_SEARCH), ...(base.webSearch ?? {}) }, - lsp: { - ...(fallback.lsp ?? {}), - ...(base.lsp ?? {}), - servers: { - ...(fallback.lsp?.servers ?? {}), - ...(base.lsp?.servers ?? {}) - } - }, workspaces }; } @@ -1112,14 +1063,6 @@ export function getEffectiveLumeConfig(workspaceSlug?: string): LumeEffectiveCon ...(file.webSearch?.providers ?? {}), ...(overlay?.webSearch?.providers ?? {}) } - }, - lsp: { - ...(file.lsp ?? {}), - ...(overlay?.lsp ?? {}), - servers: { - ...(file.lsp?.servers ?? {}), - ...(overlay?.lsp?.servers ?? {}) - } } }; syncWebSearchEnvVars(effective.webSearch ?? {}); diff --git a/apps/web/src/components/settings/tool-metadata.ts b/apps/web/src/components/settings/tool-metadata.ts index 25fd0b5b3..0b5fe36ab 100644 --- a/apps/web/src/components/settings/tool-metadata.ts +++ b/apps/web/src/components/settings/tool-metadata.ts @@ -220,15 +220,6 @@ export const TOOL_METADATA: Record = { riskLevel: 'low', }, - // === LSP === - lsp: { - name: 'lsp', - label: 'LSP', - description: 'LSP 代码智能查询', - category: 'read', - riskLevel: 'low', - }, - // === Todo === todo_write: { name: 'todo_write', diff --git a/apps/web/src/components/skills/skill-tool-definitions.ts b/apps/web/src/components/skills/skill-tool-definitions.ts index f6ace9089..1738dda84 100644 --- a/apps/web/src/components/skills/skill-tool-definitions.ts +++ b/apps/web/src/components/skills/skill-tool-definitions.ts @@ -3,7 +3,6 @@ export type SkillSystemToolGroupId = | 'file-read' | 'file-write' | 'search' - | 'code-intelligence' | 'web' | 'data' | 'memory' diff --git a/apps/web/src/components/skills/system-tools-state.test.ts b/apps/web/src/components/skills/system-tools-state.test.ts index 1e5b06ebc..c2f2c7bc9 100644 --- a/apps/web/src/components/skills/system-tools-state.test.ts +++ b/apps/web/src/components/skills/system-tools-state.test.ts @@ -82,20 +82,6 @@ describe('system-tools-state', () => { expect('policyEntry' in row!).toBe(false) }) - test('keeps code intelligence visible as a locked read-only tool group', () => { - const rows = buildSystemToolRows(['group:lsp']) - const row = rows.find((item) => item.id === 'code-intelligence') - - expect(row).toMatchObject({ - label: '代码智能', - description: 'LSP 代码理解与符号查询', - count: 1, - enabled: true, - locked: true, - }) - expect('policyEntry' in row!).toBe(false) - }) - test('counts the locked Agent group as sub-agent dispatch plus skill invocation', () => { const rows = buildSystemToolRows() diff --git a/apps/web/src/components/skills/system-tools-state.ts b/apps/web/src/components/skills/system-tools-state.ts index 8c22d7486..56d1b87f4 100644 --- a/apps/web/src/components/skills/system-tools-state.ts +++ b/apps/web/src/components/skills/system-tools-state.ts @@ -26,8 +26,6 @@ export function isToolInGroup(toolName: string, groupId: string): boolean { return ['write', 'edit', 'notebook_edit'].includes(toolName) case 'search': return ['find', 'grep', 'ls'].includes(toolName) - case 'code-intelligence': - return toolName === 'lsp' case 'web': return ['web_search', 'web_fetch'].includes(toolName) case 'data': @@ -131,13 +129,6 @@ export const SYSTEM_TOOL_GROUPS: SystemToolGroup[] = [ count: countToolsByGroup('search'), locked: true, }, - { - id: 'code-intelligence', - label: '代码智能', - description: 'LSP 代码理解与符号查询', - count: countToolsByGroup('code-intelligence'), - locked: true, - }, { id: 'web', label: 'Web', diff --git a/apps/web/src/hooks/lifecycle-event-adapter.test.ts b/apps/web/src/hooks/lifecycle-event-adapter.test.ts index d0775e525..eb1794b11 100644 --- a/apps/web/src/hooks/lifecycle-event-adapter.test.ts +++ b/apps/web/src/hooks/lifecycle-event-adapter.test.ts @@ -574,50 +574,6 @@ test('advisor.reviewed → 旧路同形:severity 白名单外丢弃,summary/mode expect(Object.keys(fallback[0] as object)).not.toContain('durationMs') }) -test('lsp.diagnostics → lsp.diagnostics.updated:字段逐字对齐;filePath/sha256 缺失丢弃', () => { - const state = createLifecycleAdapterState() - const diagnostics = { - servers: ['tsserver'], - total: 2, - errors: 1, - warnings: 1, - truncated: false, - items: [{ message: 'TS2304', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } } }], - } - const events = adaptLifecycleEvent(envelope(1, 'run', 'event', null, { - type: 'lsp.diagnostics', - filePath: 'src/a.ts', - mutationVersion: 3, - sha256: 'abc', - delayed: true, - toolUseId: 'call-9', - diagnostics, - }), state) - expect(events).toEqual([{ - id: 'lifecycle:1:lsp.diagnostics.updated', - type: 'lsp.diagnostics.updated', - threadId: 't1', - runId: 'r1', - createdAt: new Date(TS + 1).toISOString(), - toolUseId: 'call-9', - filePath: 'src/a.ts', - mutationVersion: 3, - sha256: 'abc', - delayed: true, - diagnostics, - }]) - expect((events[0] as { diagnostics: unknown }).diagnostics).toBe(diagnostics) - - expect(adaptLifecycleEvent(envelope(2, 'run', 'event', null, { - type: 'lsp.diagnostics', - filePath: '', - mutationVersion: 0, - sha256: 'x', - delayed: false, - diagnostics, - }), state)).toEqual([]) -}) - test('coding.report → coding.report.updated:report 同引用透传', () => { const state = createLifecycleAdapterState() const report = { diff --git a/apps/web/src/hooks/lifecycle-event-adapter.ts b/apps/web/src/hooks/lifecycle-event-adapter.ts index 21b3eeff4..df67e0e99 100644 --- a/apps/web/src/hooks/lifecycle-event-adapter.ts +++ b/apps/web/src/hooks/lifecycle-event-adapter.ts @@ -31,7 +31,7 @@ import type { AgentEventBusSource } from './useAgentEventBus' * 而非 SDK 流,由 sidecar lume-runner 第二注入路径直发) * - background.task → background.task.completed(批次4,late task_notification 旁路 * + projector 主流双入口;streaming 副作用见 consumeBusEnvelope) - * - todo.state/advisor.reviewed/lsp.diagnostics/coding.report → 旧路同形事件(批次5, + * - todo.state/advisor.reviewed/coding.report → 旧路同形事件(批次5, * sidecar 第二入口双发,载荷同引用;字段对齐 run-item-events 对应构造) * - context.compaction 三态 → 同名三事件(批次4;trigger 真值+outcome 已由 projector * 透传(加固批次 detail.trigger/detail.outcome,adapter outcome 取 isError 等价), @@ -327,25 +327,6 @@ export function adaptLifecycleEvent( }] } - // 批次5:lsp.diagnostics → 旧路 lsp.diagnostics.updated。T2 detail 字段已与旧路逐字 - // 对齐(强类型直通);filePath/sha256 缺失丢弃——同旧路 gate(T4 注入侧同 gate,双保险)。 - if (detail.type === 'lsp.diagnostics') { - if (!detail.filePath || !detail.sha256) return [] - return [{ - id: `lifecycle:${envelope.seq}:lsp.diagnostics.updated`, - type: 'lsp.diagnostics.updated' as const, - ...base, - ...(detail.toolUseId !== undefined ? { toolUseId: detail.toolUseId } : {}), - filePath: detail.filePath, - mutationVersion: detail.mutationVersion, - sha256: detail.sha256, - delayed: detail.delayed, - // detail.diagnostics.artifact 标注为 unknown(T4 透传 SDK 批次原引用),运行时与 - // 旧路同构——引用透传,宽标注处 cast(同批次3 items 模式) - diagnostics: detail.diagnostics as Extract['diagnostics'], - }] - } - // 批次5:coding.report → 旧路 coding.report.updated。detail.report 与旧路 codingReport // 同引用(T1 终表判迁:run.completed/coding.report 双入口)——宽标注处 cast 透传。 if (detail.type === 'coding.report') { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8ea5dd098..97a7e5eeb 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -359,7 +359,6 @@ Credentials live in the injected provider, not the SDK. The remaining variable: | **McpAuth** | Start MCP authentication flows | | **CronCreate/Delete/List** | Scheduled task management | | **RemoteTrigger** | Remote agent triggers | -| **LSP** | Language Server Protocol (code intelligence) | | **Config** | Get/set session config by setting key | | **TodoWrite** | Replace the session todo list | diff --git a/packages/sdk/src/engine.test.ts b/packages/sdk/src/engine.test.ts index 50f536cc6..072538a5b 100644 --- a/packages/sdk/src/engine.test.ts +++ b/packages/sdk/src/engine.test.ts @@ -470,95 +470,6 @@ describe("QueryEngine turn limits", () => { expect(liveEvents).toHaveLength(1) }) - test("injects delayed LSP diagnostics into the next model request without starting a hidden turn", async () => { - const provider = new StaticProvider([ - { - content: [ - { type: "tool_use", id: "edit-1", name: "Edit", input: {} }, - { type: "tool_use", id: "write-1", name: "Write", input: {} }, - ], - stopReason: "tool_use", - usage: { input_tokens: 1, output_tokens: 1 }, - }, - { - content: [{ type: "text", text: "fixed after diagnostics" }], - stopReason: "end_turn", - usage: { input_tokens: 1, output_tokens: 1 }, - }, - ]) - const asyncEvents: SDKMessage[] = [] - const engine = new QueryEngine({ - cwd: process.cwd(), - model: "test-model", - provider, - tools: [ - { - name: "Edit", - description: "edit", - inputSchema: { type: "object", properties: {} }, - async call(_input, context) { - setTimeout(() => { - context.emitEvent?.({ - type: "system", - subtype: "lsp_diagnostics", - session_id: "session", - tool_use_id: "edit-1", - file_path: "src/example.ts", - mutation_version: 1, - sha256: "abc", - delayed: true, - diagnostics: { - servers: ["typescript-language-server"], - total: 1, - errors: 1, - warnings: 0, - truncated: false, - items: [{ - severity: 1, - message: "Cannot find name 'missing'.", - range: { - start: { line: 2, character: 4 }, - end: { line: 2, character: 11 }, - }, - }], - }, - }) - }, 0) - return { type: "tool_result" as const, tool_use_id: "", content: "edited" } - }, - }, - { - name: "Write", - description: "write", - inputSchema: { type: "object", properties: {} }, - async call() { - await wait(20) - return { type: "tool_result" as const, tool_use_id: "", content: "written" } - }, - }, - ], - systemPrompt: "test", - maxTurns: 2, - maxTokens: 256, - includePartialMessages: false, - canUseTool: async () => ({ behavior: "allow" }), - onAsyncEvent: (event) => asyncEvents.push(event), - }) - - await collectEvents(engine) - - expect(provider.requests).toHaveLength(2) - expect(provider.requests[1]?.messages).toContainEqual(expect.objectContaining({ - role: "runtime", - content: expect.stringContaining(""), - })) - expect(provider.requests[1]?.messages).toContainEqual(expect.objectContaining({ - role: "runtime", - content: expect.stringContaining("Cannot find name 'missing'."), - })) - expect(asyncEvents).toHaveLength(1) - }) - test("treats a natural completion on the final allowed turn as success", async () => { const engine = new QueryEngine({ cwd: process.cwd(), @@ -3377,113 +3288,6 @@ describe("QueryEngine non-stream retry events (#360)", () => { }, 20_000) }) -describe("QueryEngine delayed diagnostics persistence (#359)", () => { - test("re-injects pending diagnostics after a prompt-too-long compaction retry", async () => { - let calls = 0 - const requests: CreateMessageParams[] = [] - const provider: LLMProvider = { - apiType: "anthropic-messages", - async createMessage(params) { - requests.push(params) - calls += 1 - if (calls === 2) { - const error = new Error("prompt is too long") as Error & { status: number } - error.status = 400 - throw error - } - if (calls === 1) { - return { - content: [ - { type: "tool_use", id: "edit-9", name: "Edit", input: {} }, - { type: "tool_use", id: "settle-9", name: "Settle", input: {} }, - ], - stopReason: "tool_use", - usage: { input_tokens: 1, output_tokens: 1 } - } - } - return { - content: [{ type: "text", text: "done" }], - stopReason: "end_turn", - usage: { input_tokens: 1, output_tokens: 1 } - } - } - } - const engine = new QueryEngine({ - cwd: process.cwd(), - model: "test-model", - provider, - tools: [{ - name: "Edit", - description: "edit", - inputSchema: { type: "object", properties: {} }, - async call(_input, context) { - setTimeout(() => { - context.emitEvent?.({ - type: "system", - subtype: "lsp_diagnostics", - session_id: "session", - tool_use_id: "edit-9", - file_path: "src/example.ts", - mutation_version: 1, - sha256: "abc", - delayed: true, - diagnostics: { - servers: ["typescript-language-server"], - total: 1, - errors: 1, - warnings: 0, - truncated: false, - items: [{ - severity: 1, - message: "Cannot find name 'missing'.", - range: { start: { line: 2, character: 4 }, end: { line: 2, character: 11 } } - }] - } - }) - }, 0) - return { type: "tool_result" as const, tool_use_id: "", content: "edited" } - } - }, { - name: "Settle", - description: "settle", - inputSchema: { type: "object", properties: {} }, - async call() { - // Give the deferred diagnostics emission room to land after Edit - // returned but before the next provider request. - await wait(30) - return { type: "tool_result" as const, tool_use_id: "", content: "settled" } - } - }], - systemPrompt: "test", - maxTurns: 3, - maxTokens: 256, - includePartialMessages: false, - canUseTool: async () => ({ behavior: "allow" }), - contextController: { - shouldAutoCompact: () => false, - async compactConversation({ messages }) { - return { - compactedMessages: [ - { role: "user", content: "[Previous conversation summary]\n\nretry summary" }, - ...messages.slice(-1) - ], - summary: "retry summary" - } - } - } - }) - - await expect(collectResult(engine)).resolves.toMatchObject({ subtype: "success" }) - - const diagnosticRuntime = (request?: { messages: unknown[] }) => - JSON.stringify((request?.messages ?? []).filter((message: any) => message.role === "runtime")) - // Injected before the failed request… - expect(diagnosticRuntime(requests[1])).toContain("Cannot find name 'missing'.") - // …and still present on the compaction retry after it. - expect(diagnosticRuntime(requests[2])).toContain("Cannot find name 'missing'.") - }) -}) - describe("QueryEngine cost estimation (#352)", () => { test("includes cache read/write tokens in billing totals", async () => { const provider = new StaticProvider([{ diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 395ac3410..b57507cbb 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -81,21 +81,6 @@ import { matchesAnyToolPattern } from './utils/tool-approval.js' import { FileStateCache } from './utils/fileCache.js' import { createExecuteTool, createToolSearchTool } from './tools/tool-search.js' -function renderLspDiagnosticsForModel( - event: Extract, -): string { - const header = `Delayed LSP diagnostics for ${event.file_path} (mutation ${event.mutation_version})` - if (event.diagnostics.items.length === 0) return `${header}: no new diagnostics` - return [ - header, - ...event.diagnostics.items.map((diagnostic) => { - const position = `${diagnostic.range.start.line + 1}:${diagnostic.range.start.character + 1}` - const severity = diagnostic.severity === 1 ? 'error' : diagnostic.severity === 2 ? 'warning' : 'info' - return `- ${position} ${severity}${diagnostic.code !== undefined ? ` [${diagnostic.code}]` : ''}: ${diagnostic.message}` - }), - ].join('\n') -} - // ============================================================================ // Tool format conversion // ============================================================================ @@ -460,7 +445,6 @@ export class QueryEngine { }> = [] private fileStateCache = new FileStateCache() private workingDirectory: string - private pendingLspDiagnostics: Array> = [] /** Tool calls skipped or interrupted by an abort during the current run. */ private abortedPendingToolCalls: Array<{ id: string; name: string; input: unknown }> = [] private repeatedToolCalls = new Map() @@ -1190,18 +1174,6 @@ export class QueryEngine { const apiMessages = await this.microCompactForProvider( normalizeMessagesForAPI(hydratedMessages) as NormalizedMessageParam[], ) - // Non-destructive read: the request may still fail (prompt-too-long - // compaction retry) and the diagnostics must survive for the next - // attempt. Cleared only once a response actually came back. - const delayedLspDiagnostics = [...this.pendingLspDiagnostics] - if (delayedLspDiagnostics.length > 0) { - apiMessages.push({ - role: 'runtime', - content: `\n${delayedLspDiagnostics - .map((event) => renderLspDiagnosticsForModel(event)) - .join('\n\n')}\n`, - }) - } const transientRuntimeContext = [ internalContextBlocks.length > 0 ? `\n${internalContextBlocks.join('\n\n')}\n` @@ -1429,8 +1401,6 @@ export class QueryEngine { return } - // The request succeeded: diagnostics injected above are consumed. - this.pendingLspDiagnostics = [] this.messages = releaseEphemeralImageReferences(this.messages as any[]) as NormalizedMessageParam[] // Track API timing @@ -2075,8 +2045,7 @@ export class QueryEngine { context.emitEvent?.(event) return } - if (event.type === 'system' && (event.subtype === 'task_notification' || event.subtype === 'lsp_diagnostics')) { - if (event.subtype === 'lsp_diagnostics') this.pendingLspDiagnostics.push(event) + if (event.type === 'system' && event.subtype === 'task_notification') { try { this.config.onAsyncEvent?.(event) } catch { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 165a01d67..5279bb815 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -129,10 +129,6 @@ export { CORE_TOOL_NAMES, splitDeferredTools, - // LSP - LSPTool, - LSPApplyTool, - // Todo createTodoTool, @@ -151,51 +147,6 @@ export { type ProcessJob, } from './tools/process-job-registry.js' -// LSP protocol and client manager -export { - collectLspDiagnostics, - encodeLspMessage, - getLspClient, - getLspClientsForFile, - notifyLspFileChanged, - notifyLspFileClosed, - notifyLspWatchedFiles, - requestLspClients, - resolveLspServerConfig, - resolveLspServerConfigsForFile, - setLspIdleTimeout, - shutdownLspClients, - warmupLspClients, -} from './lsp/client.js' -export type { - LspAggregatedDiagnostic, - LspClient, - LspClientState, - LspCreateFile, - LspDeleteFile, - LspDiagnostic, - LspLocation, - LspLocationLink, - LspPosition, - LspRange, - LspRenameFile, - LspServerCapabilities, - LspServerConfig, - LspServerStatus, - LspTextDocumentEdit, - LspTextEdit, - LspWatchedFileChange, - LspWorkspaceEdit, -} from './lsp/client.js' -export { - DEFAULT_LSP_SERVERS, - findLspWorkspaceRoot, - resolveLspExecutable, - supportsLspFile, -} from './lsp/registry.js' -export type { LspRegistryServer, LspServerRole } from './lsp/registry.js' -export { clearLspWritethroughState } from './lsp/writethrough.js' - // -------------------------------------------------------------------------- // MCP Client // -------------------------------------------------------------------------- diff --git a/packages/sdk/src/lsp/adapters.test.ts b/packages/sdk/src/lsp/adapters.test.ts deleted file mode 100644 index e92594140..000000000 --- a/packages/sdk/src/lsp/adapters.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { collectLspAdapterDiagnostics, parseSwiftLintDiagnostics } from './adapters.js' -import { resolveShellInvocation, shellKind } from '../utils/shell-invocation.js' - -describe('SwiftLint LSP adapter', () => { - test('normalizes JSON diagnostics to LSP positions and severities', () => { - const result = parseSwiftLintDiagnostics(JSON.stringify([ - { - line: 3, - character: 5, - severity: 'Warning', - rule_id: 'line_length', - reason: 'Line should be shorter.', - }, - { - line: 1, - character: 1, - severity: 'Error', - reason: 'Invalid declaration.', - }, - ])) - - expect(result?.diagnostics).toMatchObject({ - total: 2, - errors: 1, - warnings: 1, - items: [ - { - severity: 2, - code: 'line_length', - range: { start: { line: 2, character: 4 } }, - }, - { - severity: 1, - range: { start: { line: 0, character: 0 } }, - }, - ], - }) - }) - - test('degrades cleanly for malformed CLI output', () => { - expect(parseSwiftLintDiagnostics('swiftlint: unknown output')).toBeUndefined() - expect(parseSwiftLintDiagnostics('{}')).toBeUndefined() - }) - - test('shellKind classifies resolved shell executables (#328)', () => { - expect(shellKind('powershell.exe')).toBe('powershell') - expect(shellKind('C:\\Windows\\System32\\WindowsPowerShell\\1.0\\powershell.exe')).toBe('powershell') - expect(shellKind('/usr/bin/pwsh')).toBe('powershell') - expect(shellKind('bash.exe')).toBe('bash') - expect(shellKind('/usr/bin/bash')).toBe('bash') - expect(shellKind('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash') - expect(shellKind('sh')).toBe('bash') - }) - - test('picks the PowerShell call operator from the resolved shell, not the platform (#328)', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-adapter-')) - try { - await writeFile(join(root, '.swiftlint.yml'), '') - const swiftlint = join(root, process.platform === 'win32' ? 'swiftlint.cmd' : 'swiftlint') - await writeFile(swiftlint, '') - // resolveLspExecutable requires the exec bit on POSIX; without it the - // adapter silently finds no swiftlint and returns undefined. - if (process.platform !== 'win32') await chmod(swiftlint, 0o755) - - let captured: string | undefined - const context = { - cwd: root, - // Pin the executable explicitly so resolution never falls through to - // a real swiftlint installed on the machine's PATH. - toolConfig: { lsp: { servers: { swiftlint: { command: swiftlint } } } }, - executeNestedTool: async (invocation: { params: { command: string } }) => { - captured = invocation.params.command - return { content: '[]' } - }, - } - const filePath = join(root, 'Sources', 'Demo.swift') - const result = await collectLspAdapterDiagnostics(filePath, context as any) - - expect(result).toBeDefined() - expect(captured).toBeDefined() - const command = captured! - const withoutOperator = command.replace(/^& /, '') - expect(withoutOperator).toBe(`${swiftlint} lint --path ${resolve(filePath)} --quiet --reporter json`) - // The operator decision must match exactly what the Bash tool will - // resolve for this command line: PowerShell dialect gets '& ', Git Bash - // must not (a leading '&' is a syntax error there). - const expectedPowerShell = shellKind(resolveShellInvocation(withoutOperator).command) === 'powershell' - expect(command.startsWith('& ')).toBe(expectedPowerShell) - if (process.platform !== 'win32') { - expect(command.startsWith('& ')).toBe(false) - } - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/sdk/src/lsp/adapters.ts b/packages/sdk/src/lsp/adapters.ts deleted file mode 100644 index 2948c4a77..000000000 --- a/packages/sdk/src/lsp/adapters.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { resolve } from 'node:path' -import type { LspDiagnosticBatch, ToolContext } from '../types.js' -import { findLspWorkspaceRoot, resolveLspExecutable } from './registry.js' -import { resolveShellInvocation, shellKind } from '../utils/shell-invocation.js' - -export async function collectLspAdapterDiagnostics( - filePath: string, - context: ToolContext, -): Promise<{ server: string; diagnostics: LspDiagnosticBatch } | undefined> { - if (!filePath.toLowerCase().endsWith('.swift') || !context.executeNestedTool) return undefined - const configured = swiftLintConfig(context) - if (configured.disabled) return undefined - const rootMarkers = configured.rootMarkers ?? [ - '.swiftlint.yml', - '.swiftlint.yaml', - 'Package.swift', - '*.xcodeproj', - ] - const root = await findLspWorkspaceRoot(context.cwd, filePath, rootMarkers) - if (!root) return undefined - const command = await resolveLspExecutable(configured.command ?? 'swiftlint', root, configured.cwd) - if (!command) return undefined - const cliCommand = `${quote(command)} lint --path ${quote(resolve(filePath))} --quiet --reporter json` - // PowerShell needs an explicit call operator to invoke a quoted path, but a - // leading '&' is a syntax error in Git Bash — pick the operator from the - // shell the Bash tool will actually resolve, not from the platform (#328). - const callOperator = shellKind(resolveShellInvocation(cliCommand).command) === 'powershell' ? '& ' : '' - const result = await context.executeNestedTool({ - toolName: 'Bash', - params: { - command: `${callOperator}${cliCommand}`, - purpose: 'lsp-diagnostics', - description: 'SwiftLint diagnostics', - }, - }) - const output = toolOutput(result) - return parseSwiftLintDiagnostics(output) -} - -function swiftLintConfig(context: ToolContext): { - disabled: boolean - command?: string - cwd?: string - rootMarkers?: string[] -} { - const lsp = context.toolConfig?.lsp - if (!lsp || typeof lsp !== 'object' || Array.isArray(lsp)) return { disabled: false } - const servers = (lsp as Record).servers - if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return { disabled: false } - const value = (servers as Record).swiftlint - if (!value || typeof value !== 'object' || Array.isArray(value)) return { disabled: false } - const record = value as Record - return { - disabled: record.disabled === true, - ...(typeof record.command === 'string' ? { command: record.command } : {}), - ...(typeof record.cwd === 'string' ? { cwd: resolve(context.cwd, record.cwd) } : {}), - ...(Array.isArray(record.rootMarkers) - ? { rootMarkers: record.rootMarkers.filter((marker): marker is string => typeof marker === 'string') } - : {}), - } -} - -export function parseSwiftLintDiagnostics( - output: string, -): { server: string; diagnostics: LspDiagnosticBatch } | undefined { - let values: unknown - try { - values = JSON.parse(output) - } catch { - return undefined - } - if (!Array.isArray(values)) return undefined - const items: LspDiagnosticBatch['items'] = values.flatMap((value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return [] - const record = value as Record - if (typeof record.reason !== 'string') return [] - const line = Math.max(Number(record.line ?? 1) - 1, 0) - const character = Math.max(Number(record.character ?? 1) - 1, 0) - return [{ - server: 'swiftlint', - source: 'swiftlint', - severity: record.severity === 'Warning' ? 2 as const : 1 as const, - ...(typeof record.rule_id === 'string' ? { code: record.rule_id } : {}), - message: record.reason, - range: { - start: { line, character }, - end: { line, character: character + 1 }, - }, - }] - }) - return { - server: 'swiftlint', - diagnostics: { - servers: ['swiftlint'], - total: items.length, - errors: items.filter((item) => item.severity === 1).length, - warnings: items.filter((item) => item.severity === 2).length, - truncated: false, - items, - }, - } -} - -function quote(value: string): string { - // Double quotes leave backticks and $(...) live in both POSIX sh and - // PowerShell; single-quote with '' escaping instead (#198) - return /^[a-zA-Z0-9_./:\\-]+$/.test(value) ? value : `'${value.replace(/'/g, "''")}'` -} - -function toolOutput(result: { content?: unknown }): string { - const execution = (result as { - _meta?: { execution?: { stdoutPreview?: unknown } } - })._meta?.execution - if (typeof execution?.stdoutPreview === 'string') return execution.stdoutPreview - if (typeof result.content === 'string') return result.content - if (Array.isArray(result.content)) { - return result.content.map((block) => - block && typeof block === 'object' && 'text' in block ? String(block.text) : '' - ).join('') - } - return '' -} diff --git a/packages/sdk/src/lsp/client.test.ts b/packages/sdk/src/lsp/client.test.ts deleted file mode 100644 index 79112cad9..000000000 --- a/packages/sdk/src/lsp/client.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - applyTextEdits, - encodeLspMessage, - getLspClient, - getLspClientsForFile, - languageIdForPath, - LspClient, - parseLspMessages, - resolveLspServerConfigsForFile, - setLspWriteTimeout, - shutdownLspClients, - warmupLspClients, -} from './client.js' -import { DEFAULT_LSP_SERVERS, findLspWorkspaceRoot, resolveLspExecutable } from './registry.js' - -describe('LSP protocol helpers', () => { - test('round trips framed messages and preserves incomplete frames', () => { - const first = encodeLspMessage({ jsonrpc: '2.0', id: 1, result: { ok: true } }) - const second = encodeLspMessage({ jsonrpc: '2.0', method: 'initialized' }) - const split = first.byteLength - 3 - const partial = parseLspMessages(first.subarray(0, split)) - expect(partial.messages).toEqual([]) - const parsed = parseLspMessages(Buffer.concat([first.subarray(split), second]), partial.rest) - expect(parsed.messages).toEqual([ - { jsonrpc: '2.0', id: 1, result: { ok: true } }, - { jsonrpc: '2.0', method: 'initialized' }, - ]) - expect(parsed.rest.byteLength).toBe(0) - expect(parseLspMessages(Buffer.concat([Buffer.from('wrapper log\r\n\r\n'), first])).messages).toEqual([ - { jsonrpc: '2.0', id: 1, result: { ok: true } }, - ]) - }) - - test('applies UTF-16 line edits from bottom to top', () => { - expect(applyTextEdits('const foo = 1\nfoo\n', [ - { range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } }, newText: 'bar' }, - { range: { start: { line: 0, character: 6 }, end: { line: 0, character: 9 } }, newText: 'bar' }, - ])).toBe('const bar = 1\nbar\n') - }) - - test('preserves ordered insertions and ignores duplicate server edits', () => { - expect(applyTextEdits('x', [ - { range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: 'A' }, - { range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: 'B' }, - { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, newText: 'y' }, - { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, newText: 'y' }, - ])).toBe('yAB') - }) - - test('maps registry source extensions to LSP language ids', () => { - expect(languageIdForPath('src/App.tsx')).toBe('typescriptreact') - expect(languageIdForPath('src/index.ts')).toBe('typescript') - expect(languageIdForPath('src/index.js')).toBe('javascript') - expect(languageIdForPath('src/main.rs')).toBe('rust') - expect(languageIdForPath('src/main.py')).toBe('python') - expect(languageIdForPath('Dockerfile')).toBe('dockerfile') - expect(languageIdForPath('infra/main.tf')).toBe('terraform') - expect(languageIdForPath('README.md')).toBe('markdown') - }) - - test('selects all matching project servers and respects root markers', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-config-')) - try { - const tsServer = join(root, process.platform === 'win32' ? 'ts-server.cmd' : 'ts-server') - const eslintServer = join(root, process.platform === 'win32' ? 'eslint-server.cmd' : 'eslint-server') - await writeFile(tsServer, process.platform === 'win32' ? '@exit /b 0' : '#!/bin/sh\nexit 0\n') - await writeFile(eslintServer, process.platform === 'win32' ? '@exit /b 0' : '#!/bin/sh\nexit 0\n') - if (process.platform !== 'win32') { - await chmod(tsServer, 0o755) - await chmod(eslintServer, 0o755) - } - await writeFile(join(root, 'package.json'), '{}') - await writeFile(join(root, 'lsp.json'), JSON.stringify({ servers: { - ts: { command: tsServer, fileTypes: ['.ts'], rootMarkers: ['package.json'], priority: 10 }, - eslint: { command: eslintServer, fileTypes: ['ts'], role: 'linter' }, - python: { command: 'pyright-langserver', fileTypes: ['.py'] }, - }})) - const servers = await resolveLspServerConfigsForFile(root, undefined, join(root, 'src.ts')) - expect(servers.map((server) => server.name)).toEqual(['ts', 'eslint']) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('keeps direct run config and legacy environment overrides compatible', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-run-config-')) - const previousCommand = process.env.LUME_LSP_COMMAND - const previousArgs = process.env.LUME_LSP_ARGS - try { - const source = join(root, 'index.ts') - await writeFile(source, '') - const direct = await resolveLspServerConfigsForFile(root, { - lsp: { command: process.execPath, args: ['direct-server.mjs'] }, - }, source) - expect(direct).toMatchObject([{ - name: 'default', - command: process.execPath, - args: ['direct-server.mjs'], - }]) - - process.env.LUME_LSP_COMMAND = process.execPath - process.env.LUME_LSP_ARGS = 'legacy-server.mjs --stdio' - const legacy = await resolveLspServerConfigsForFile(root, { lsp: { enabled: true } }, source) - expect(legacy).toMatchObject([{ - name: 'legacy', - command: process.execPath, - args: ['legacy-server.mjs', '--stdio'], - }]) - } finally { - if (previousCommand === undefined) delete process.env.LUME_LSP_COMMAND - else process.env.LUME_LSP_COMMAND = previousCommand - if (previousArgs === undefined) delete process.env.LUME_LSP_ARGS - else process.env.LUME_LSP_ARGS = previousArgs - await rm(root, { recursive: true, force: true }) - } - }) - - test('ships the complete built-in registry and resolves project shims', async () => { - expect(Object.keys(DEFAULT_LSP_SERVERS)).toHaveLength(53) - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-registry-')) - try { - await mkdir(join(root, 'node_modules', '.bin'), { recursive: true }) - const shim = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'demo.cmd' : 'demo') - await writeFile(shim, process.platform === 'win32' ? '@exit /b 0' : '#!/bin/sh\nexit 0\n') - if (process.platform !== 'win32') await chmod(shim, 0o755) - expect(await resolveLspExecutable('demo', root)).toBe(shim) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('supports glob root markers', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-marker-')) - try { - await writeFile(join(root, 'demo.sln'), '') - await mkdir(join(root, 'src')) - expect(await findLspWorkspaceRoot(root, join(root, 'src', 'Program.cs'), ['*.sln'])).toBe(root) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('serializes concurrent document lock operations', async () => { - const client = Object.create(LspClient.prototype) as LspClient - const events: string[] = [] - const operation = (name: string) => async () => { - events.push(`start:${name}`) - await new Promise((resolve) => setTimeout(resolve, 20)) - events.push(`end:${name}`) - } - await Promise.all([ - client['withDocumentLock']('file:///lock-demo', operation('a')), - client['withDocumentLock']('file:///lock-demo', operation('b')), - client['withDocumentLock']('file:///lock-demo', operation('c')), - ]) - expect(events).toEqual([ - 'start:a', 'end:a', - 'start:b', 'end:b', - 'start:c', 'end:c', - ]) - }) - - test('fails the client when the server never drains stdin writes (#327)', async () => { - setLspWriteTimeout(30) - try { - const client = Object.create(LspClient.prototype) as any - client.writeQueue = Promise.resolve() - client.lastActivity = 0 - client.dead = false - client.initialized = true - client.disposed = false - client.pending = new Map() - let dead = false - client.onDead = () => { dead = true } - // Accepts the write but never invokes the callback: a wedged pipe. - client.process = { stdin: { writable: true, write: () => undefined } } - - await expect(client['send']({ jsonrpc: '2.0', method: 'test/hang' })).rejects.toThrow(/timed out/i) - expect(dead).toBe(true) - expect(client.dead).toBe(true) - } finally { - setLspWriteTimeout(10_000) - } - }) - - test('resolves send once the write callback fires and leaves the client alive (#327)', async () => { - setLspWriteTimeout(30) - try { - const client = Object.create(LspClient.prototype) as any - client.writeQueue = Promise.resolve() - client.lastActivity = 0 - client.dead = false - client.initialized = true - client.disposed = false - client.pending = new Map() - let dead = false - client.onDead = () => { dead = true } - client.process = { stdin: { writable: true, write: (_body: Buffer, callback: () => void) => callback() } } - - await expect(client['send']({ jsonrpc: '2.0', method: 'test/ok' })).resolves.toBeUndefined() - expect(dead).toBe(false) - - // The timer must be released after success rather than firing later. - await new Promise((resolve) => setTimeout(resolve, 60)) - expect(dead).toBe(false) - } finally { - setLspWriteTimeout(10_000) - } - }) - - test('spawns .cmd server shims through cmd.exe on Windows', async () => { - if (process.platform !== 'win32') return - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-cmd-shim-')) - const script = join(root, 'server.mjs') - const shim = join(root, 'server.cmd') - const source = join(root, 'index.ts') - try { - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, '') - await writeFile(script, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + Number(match[1]))) - buffer = Buffer.alloc(0) - if (message.method === 'initialize') send({ jsonrpc: '2.0', id: message.id, result: { capabilities: {} } }) - else if (message.method === 'shutdown' || message.id !== undefined) send({ jsonrpc: '2.0', id: message.id, result: null }) -}) -`) - await writeFile(shim, `@"${process.execPath}" "%~dp0server.mjs" %*\r\n`) - const client = await getLspClient(root, { lsp: { servers: { - test: { command: shim, fileTypes: ['.ts'], rootMarkers: ['package.json'] }, - } } }, source) - expect(client.state).toBe('ready') - } finally { - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }, 20_000) - - test('synchronizes documents, serves configuration sections and shuts down cleanly', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-client-')) - try { - const script = join(root, 'server.mjs') - const source = join(root, 'index.ts') - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, 'const value = 1\n') - await writeFile(script, ` -let buffer = Buffer.alloc(0) -const notifications = [] -let configuration -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - while (true) { - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const length = Number(match[1]) - if (buffer.length < end + 4 + length) return - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length)) - buffer = buffer.subarray(end + 4 + length) - if (message.id === 900 && !message.method) { - configuration = message.result - } else if (message.method === 'initialize') { - send({ jsonrpc: '2.0', id: message.id, result: { capabilities: { diagnosticProvider: true } } }) - } else if (message.method === 'initialized') { - send({ jsonrpc: '2.0', id: 900, method: 'workspace/configuration', params: { items: [{ section: 'typescript.preferences' }] } }) - } else if (message.method === 'shutdown') { - send({ jsonrpc: '2.0', id: message.id, result: null }) - } else if (message.method === 'exit') { - process.exit(0) - } else if (message.method === 'test/state') { - send({ jsonrpc: '2.0', id: message.id, result: { notifications, configuration } }) - } else if (message.method === 'textDocument/diagnostic') { - send({ jsonrpc: '2.0', id: message.id, result: { items: [{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, severity: 2, message: 'demo' }] } }) - } else if (message.method && !('id' in message)) { - notifications.push(message.method) - } - } -}) -`) - const config = { - lsp: { - servers: { - test: { - command: process.execPath, - args: [script], - fileTypes: ['.ts'], - rootMarkers: ['package.json'], - settings: { typescript: { preferences: { quoteStyle: 'single' } } }, - }, - }, - }, - } - const client = await getLspClient(root, config, source) - const before = client.getDiagnosticsSequence() - const version = await client.syncContent(source, 'const value = 2\n') - await client.notifySaved(source) - const diagnostics = await client.waitForDiagnostics(source, 1_000, undefined, version, before) - expect(diagnostics[0]?.message).toBe('demo') - await new Promise((resolve) => setTimeout(resolve, 20)) - const state = await client.request<{ notifications: string[]; configuration: unknown[] }>('test/state', {}) - expect(state.notifications).toContain('textDocument/didOpen') - expect(state.notifications).toContain('textDocument/didSave') - expect(state.configuration).toEqual([{ quoteStyle: 'single' }]) - } finally { - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }) - - test('spawns servers with the minimal default environment plus explicit env, not the host environment (#380)', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-env-')) - const secretName = 'LUME_LSP_ENV_PROBE_SECRET' - const previousSecret = process.env[secretName] - process.env[secretName] = 'top-secret-value' - try { - const script = join(root, 'server.mjs') - const source = join(root, 'index.ts') - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, '') - await writeFile(script, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - while (true) { - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + Number(match[1]))) - buffer = buffer.subarray(end + 4 + Number(match[1])) - if (message.method === 'initialize') send({ jsonrpc: '2.0', id: message.id, result: { capabilities: {} } }) - else if (message.method === 'shutdown' || (message.id !== undefined && !message.method)) send({ jsonrpc: '2.0', id: message.id, result: null }) - else if (message.method === 'test/env') { - send({ jsonrpc: '2.0', id: message.id, result: { - secret: process.env.LUME_LSP_ENV_PROBE_SECRET ?? null, - marker: process.env.LUME_LSP_ENV_PROBE_MARKER ?? null, - hasPath: typeof process.env.PATH === 'string' && process.env.PATH.length > 0, - } }) - } - } -}) -`) - const config = { - lsp: { - servers: { - test: { - command: process.execPath, - args: [script], - fileTypes: ['.ts'], - rootMarkers: ['package.json'], - env: { LUME_LSP_ENV_PROBE_MARKER: 'passed-through' }, - }, - }, - }, - } - const client = await getLspClient(root, config, source) - const env = await client.request<{ secret: string | null; marker: string | null; hasPath: boolean }>('test/env', {}) - // Host-only variables must not leak into the project-configured server; - // the explicit per-server env is the opt-in passthrough. - expect(env.secret).toBeNull() - expect(env.marker).toBe('passed-through') - expect(env.hasPath).toBe(true) - } finally { - if (previousSecret === undefined) delete process.env[secretName] - else process.env[secretName] = previousSecret - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }, 20_000) - - test('warms up a configured server and never rejects when none matches', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-warmup-')) - const empty = await mkdtemp(join(tmpdir(), 'lume-lsp-warmup-empty-')) - const script = join(root, 'server.mjs') - await writeFile(join(root, 'package.json'), '{}') - await writeFile(script, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + Number(match[1]))) - buffer = Buffer.alloc(0) - if (message.method === 'initialize') send({ jsonrpc: '2.0', id: message.id, result: { capabilities: {} } }) - else if (message.method === 'shutdown' || message.id !== undefined) send({ jsonrpc: '2.0', id: message.id, result: null }) -}) -`) - try { - const config = { lsp: { command: process.execPath, args: [script] } } - await expect(warmupLspClients(root, config, 5_000)).resolves.toBeUndefined() - const clients = await getLspClientsForFile(root, config) - expect(clients.map((client) => client.state)).toEqual(['ready']) - - // No PATH server matches the builtin registry in a bare temp dir: warmup must settle silently. - await expect(warmupLspClients(empty, undefined, 500)).resolves.toBeUndefined() - } finally { - await shutdownLspClients(root) - await shutdownLspClients(empty) - await rm(root, { recursive: true, force: true }) - await rm(empty, { recursive: true, force: true }) - } - }, 20_000) -}) diff --git a/packages/sdk/src/lsp/client.ts b/packages/sdk/src/lsp/client.ts deleted file mode 100644 index a4443fce5..000000000 --- a/packages/sdk/src/lsp/client.ts +++ /dev/null @@ -1,1177 +0,0 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { existsSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { - DEFAULT_LSP_SERVERS, - findLspWorkspaceRoot, - resolveLspExecutable, - supportsLspFile, - type LspServerRole, -} from './registry.js' -import { invalidateLspmuxCache, wrapRustAnalyzerWithLspmux } from './lspmux.js' - -export interface LspPosition { - line: number - character: number -} - -export interface LspRange { - start: LspPosition - end: LspPosition -} - -export interface LspLocation { - uri: string - range: LspRange -} - -export interface LspLocationLink { - targetUri: string - targetRange: LspRange - targetSelectionRange?: LspRange -} - -export interface LspWorkspaceEdit { - changes?: Record - documentChanges?: Array - changeAnnotations?: Record -} - -export interface LspTextDocumentEdit { - textDocument: { uri: string; version?: number | null } - edits: LspTextEdit[] -} - -export interface LspCreateFile { - kind: 'create' - uri: string - options?: { overwrite?: boolean; ignoreIfExists?: boolean } -} - -export interface LspRenameFile { - kind: 'rename' - oldUri: string - newUri: string - options?: { overwrite?: boolean; ignoreIfExists?: boolean } -} - -export interface LspDeleteFile { - kind: 'delete' - uri: string - options?: { recursive?: boolean; ignoreIfNotExists?: boolean } -} - -export interface LspDiagnostic { - range: LspRange - severity?: 1 | 2 | 3 | 4 - code?: string | number - source?: string - message: string - relatedInformation?: Array<{ location: LspLocation; message: string }> -} - -export interface LspServerCapabilities { - [key: string]: unknown - diagnosticProvider?: boolean | Record - renameProvider?: boolean | Record - codeActionProvider?: boolean | Record -} - -export interface LspTextEdit { - range: LspRange - newText: string -} - -export interface LspServerConfig { - name?: string - disabled?: boolean - command?: string - args?: string[] - cwd?: string - fileTypes?: string[] - rootMarkers?: string[] - initOptions?: Record - settings?: Record - requestTimeoutMs?: number - warmupTimeoutMs?: number - priority?: number - role?: LspServerRole - adapter?: 'swiftlint' - env?: Record - lspmux?: boolean -} - -export type ResolvedLspServerConfig = Omit & { - command: string - args: string[] - cwd?: string -} - -interface JsonRpcResponse { - id: number | string - result?: unknown - error?: { code: number; message: string; data?: unknown } -} - -const clients = new Map>() -const clientLocks = new Map>() -const failedStarts = new Map() -const resolutionCache = new Map }>() -let idleTimeoutMs: number | null = 10 * 60_000 -let idleChecker: ReturnType | undefined -// Upper bound for one stdin write; a wedged language-server pipe must fail -// the client instead of hanging Write/Edit forever (#327). -let writeTimeoutMs = 10_000 - -export function setLspWriteTimeout(timeoutMs: number): void { - writeTimeoutMs = Math.max(1, timeoutMs) -} - -export type LspClientState = 'initializing' | 'ready' | 'failed' | 'restarting' | 'disposed' - -export interface LspAggregatedDiagnostic extends LspDiagnostic { - server: string -} - -export interface LspWatchedFileChange { - uri: string - type: 1 | 2 | 3 -} - -export interface LspServerStatus { - server: string - status: 'ready' | 'error' - state: LspClientState - cwd: string - capabilities: LspServerCapabilities - openFiles: number - diagnosticsVersion: number - lastActivity: string - lspmux?: boolean -} - -export function setLspIdleTimeout(timeoutMs: number | null | undefined): void { - idleTimeoutMs = timeoutMs === undefined - ? 10 * 60_000 - : typeof timeoutMs === 'number' && timeoutMs > 0 - ? timeoutMs - : null - ensureIdleChecker() -} - -function ensureIdleChecker(): void { - if (idleTimeoutMs === null) { - if (idleChecker) clearInterval(idleChecker) - idleChecker = undefined - return - } - // Recreating the interval on every file operation would restart the 60s - // countdown and let busy sessions postpone idle disposal forever. - if (!idleChecker) { - idleChecker = setInterval(() => { - void Promise.all([...clients.entries()].map(async ([key, pending]) => { - const client = await pending.catch(() => undefined) - if (!client || !client.isIdle(idleTimeoutMs!)) return - clients.delete(key) - await client.dispose() - })) - }, 60_000) - idleChecker.unref?.() - } -} - -export function encodeLspMessage(message: unknown): Buffer { - const body = Buffer.from(JSON.stringify(message), 'utf8') - return Buffer.concat([ - Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`, 'ascii'), - body, - ]) -} - -export function parseLspMessages(input: Buffer, previous: Buffer = Buffer.alloc(0)): { - messages: unknown[] - rest: Buffer -} { - let buffer = Buffer.concat([previous, input]) - const messages: unknown[] = [] - - while (true) { - const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n')) - if (headerEnd < 0) break - const headerText = buffer.subarray(0, headerEnd).toString('ascii') - const headers = headerText.split('\r\n') - const lengthHeader = headers.find((header) => /^content-length:/i.test(header)) - if (!lengthHeader) { - const nextHeader = buffer.toString('ascii').toLowerCase().indexOf('content-length:') - if (nextHeader > 0) { - buffer = buffer.subarray(nextHeader) - continue - } - throw new Error('Invalid LSP header: missing Content-Length') - } - const length = Number(lengthHeader?.split(':', 2)[1]?.trim()) - if (!Number.isInteger(length) || length < 0) { - throw new Error('Invalid LSP Content-Length header') - } - const bodyStart = headerEnd + 4 - if (buffer.byteLength < bodyStart + length) break - const body = buffer.subarray(bodyStart, bodyStart + length).toString('utf8') - messages.push(JSON.parse(body)) - buffer = buffer.subarray(bodyStart + length) - } - - return { messages, rest: buffer } -} - -export function applyTextEdits(content: string, edits: LspTextEdit[]): string { - const positioned = edits.map((edit, index) => ({ - ...edit, - index, - start: offsetAt(content, edit.range.start), - end: offsetAt(content, edit.range.end), - })) - positioned.sort((left, right) => right.start - left.start || right.end - left.end || right.index - left.index) - const unique = positioned.filter((edit, index, all) => { - const previous = all[index - 1] - return !previous || previous.start !== edit.start || previous.end !== edit.end || previous.newText !== edit.newText || edit.start === edit.end - }) - for (let index = 0; index < unique.length - 1; index += 1) { - const current = unique[index]! - const next = unique[index + 1]! - if (current.start < next.end) { - throw new Error('LSP returned overlapping text edits') - } - } - let result = content - for (const edit of unique) { - result = `${result.slice(0, edit.start)}${edit.newText}${result.slice(edit.end)}` - } - return result -} - -function offsetAt(content: string, position: LspPosition): number { - if (position.line < 0 || position.character < 0) throw new Error('Invalid LSP position') - let line = 0 - let offset = 0 - while (line < position.line) { - const newline = content.indexOf('\n', offset) - if (newline < 0) throw new Error('LSP position is outside the document') - offset = newline + 1 - line += 1 - } - const lineEnd = content.indexOf('\n', offset) - const max = lineEnd < 0 ? content.length : lineEnd - return Math.min(offset + position.character, max) -} - -function fileUri(filePath: string): string { - return pathToFileURL(resolve(filePath)).toString() -} - -export function filePathFromUri(uri: string): string { - if (!uri.startsWith('file://')) throw new Error(`Unsupported LSP URI: ${uri}`) - return fileURLToPath(uri) -} - -function serverKey(cwd: string, server: ResolvedLspServerConfig): string { - return `${resolve(cwd)}\0${server.name ?? 'default'}\0${resolve(server.cwd ?? cwd)}\0${server.command}\0${server.args.join('\0')}` -} - -export function resolveLspServerConfig(toolConfig?: Record): ResolvedLspServerConfig { - const configured = toolConfig?.lsp - const value = configured && typeof configured === 'object' && !Array.isArray(configured) - ? configured as Record - : {} - const command = typeof value.command === 'string' && value.command.trim() - ? value.command.trim() - : process.env.LUME_LSP_COMMAND?.trim() || 'typescript-language-server' - const args = Array.isArray(value.args) - ? value.args.filter((item): item is string => typeof item === 'string') - : process.env.LUME_LSP_ARGS?.trim() - ? process.env.LUME_LSP_ARGS.trim().split(/\s+/) - : ['--stdio'] - const configuredCwd = typeof value.cwd === 'string' && value.cwd.trim() ? value.cwd : undefined - return { name: 'default', command, args, ...(configuredCwd ? { cwd: resolve(configuredCwd) } : {}) } -} - -export async function resolveLspServerConfigsForFile(cwd: string, toolConfig?: Record, filePath?: string): Promise { - const key = `${resolve(cwd)}\0${filePath ? resolve(filePath) : ''}\0${JSON.stringify(toolConfig?.lsp ?? null)}\0${process.env.LUME_LSP_COMMAND ?? ''}\0${process.env.LUME_LSP_ARGS ?? ''}` - const cached = resolutionCache.get(key) - if (cached && cached.until > Date.now()) return cached.value - if (cached) resolutionCache.delete(key) - const value = resolveLspServerConfigsForFileUncached(cwd, toolConfig, filePath) - resolutionCache.set(key, { until: Date.now() + 30_000, value }) - value.catch(() => resolutionCache.delete(key)) - return value -} - -async function resolveLspServerConfigsForFileUncached(cwd: string, toolConfig?: Record, filePath?: string): Promise { - const configured = toolConfig?.lsp - const lsp = configured && typeof configured === 'object' && !Array.isArray(configured) - ? configured as Record - : undefined - if (lsp?.enabled === false) return [] - if (lsp?.command) { - const direct = normalizeServerConfig('default', lsp, cwd) - const resolved = direct ? await resolveAvailableServer(cwd, direct, filePath, true, lsp?.useLspmux !== 'off') : undefined - return resolved ? [resolved] : [] - } - if (!lsp?.servers && process.env.LUME_LSP_COMMAND?.trim()) { - const legacy = normalizeServerConfig('legacy', { - command: process.env.LUME_LSP_COMMAND.trim(), - args: process.env.LUME_LSP_ARGS?.trim().split(/\s+/).filter(Boolean) ?? ['--stdio'], - }, cwd) - const resolved = legacy ? await resolveAvailableServer(cwd, legacy, filePath, true, false) : undefined - return resolved ? [resolved] : [] - } - const servers = lsp?.servers && typeof lsp.servers === 'object' && !Array.isArray(lsp.servers) - ? lsp.servers as Record - : lsp - ? undefined - : await readProjectLspServers(cwd) - if (servers) { - const mergedServers: Record = { ...DEFAULT_LSP_SERVERS } - for (const [name, override] of Object.entries(servers)) { - const builtIn = DEFAULT_LSP_SERVERS[name] - mergedServers[name] = builtIn && override && typeof override === 'object' && !Array.isArray(override) - ? { ...builtIn, ...override } - : override - } - const configuredCandidates = Object.entries(mergedServers) - .filter(([, value]) => !(value && typeof value === 'object' && !Array.isArray(value) && (value as Record).adapter)) - .map(([name, value]) => normalizeServerConfig(name, value, cwd)) - .filter((value): value is ResolvedLspServerConfig => Boolean(value)) - const candidates = (await Promise.all(configuredCandidates.map((candidate) => - resolveAvailableServer(cwd, candidate, filePath, true, lsp?.useLspmux !== 'off') - ))).filter((value): value is ResolvedLspServerConfig => Boolean(value)) - return sortServers(candidates) - } - const builtIns = Object.entries(DEFAULT_LSP_SERVERS).filter(([, value]) => !value.adapter).map(([name, value]) => - normalizeServerConfig(name, value, cwd) - ).filter((value): value is ResolvedLspServerConfig => Boolean(value)) - const discovered = (await Promise.all(builtIns.map((candidate) => - resolveAvailableServer(cwd, candidate, filePath, false, lsp?.useLspmux !== 'off') - ))).filter((value): value is ResolvedLspServerConfig => Boolean(value)) - return sortServers(discovered) -} - -async function readProjectLspServers(cwd: string): Promise | undefined> { - // Config lookup is bounded by the containing git repository; with no .git - // anywhere up the chain only cwd itself is consulted. Temp/shared ancestor - // directories must not be able to spawn servers (#203). - let boundary = resolve(cwd) - for (let dir = boundary; ; ) { - if (existsSync(join(dir, '.git'))) { - boundary = dir - break - } - const parent = dirname(dir) - if (parent === dir) break - dir = parent - } - - let directory = resolve(cwd) - while (true) { - for (const filename of ['lsp.json', '.lsp.json', '.lume/lsp.json']) { - try { - const parsed = JSON.parse(await readFile(resolve(directory, filename), 'utf8')) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue - const value = parsed as Record - if (value.servers && typeof value.servers === 'object' && !Array.isArray(value.servers)) return value.servers as Record - return value - } catch { - // A project-local config is optional; continue with the parent directory. - } - } - if (directory === boundary) return undefined - directory = dirname(directory) - } -} - -function normalizeServerConfig(name: string, value: unknown, workspaceRoot: string): ResolvedLspServerConfig | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - const record = value as Record - if (record.disabled === true) return undefined - const command = typeof record.command === 'string' && record.command.trim() ? record.command.trim() : undefined - if (!command) return undefined - const args = Array.isArray(record.args) ? record.args.filter((item): item is string => typeof item === 'string') : ['--stdio'] - return { - name, - command, - args, - fileTypes: Array.isArray(record.fileTypes) ? record.fileTypes.filter((item): item is string => typeof item === 'string') : [], - rootMarkers: Array.isArray(record.rootMarkers) ? record.rootMarkers.filter((item): item is string => typeof item === 'string') : [], - initOptions: record.initOptions && typeof record.initOptions === 'object' && !Array.isArray(record.initOptions) ? record.initOptions as Record : {}, - settings: record.settings && typeof record.settings === 'object' && !Array.isArray(record.settings) ? record.settings as Record : {}, - requestTimeoutMs: typeof record.requestTimeoutMs === 'number' && record.requestTimeoutMs > 0 ? Math.min(record.requestTimeoutMs, 300_000) : 20_000, - warmupTimeoutMs: typeof record.warmupTimeoutMs === 'number' && record.warmupTimeoutMs > 0 ? record.warmupTimeoutMs : 5_000, - priority: typeof record.priority === 'number' ? record.priority : 0, - role: record.role === 'linter' ? 'linter' : record.role === 'primary' ? 'primary' : undefined, - adapter: record.adapter === 'swiftlint' ? 'swiftlint' : undefined, - // Explicit per-server environment passthrough; merged over the minimal - // default spawn environment, never the host environment (#380). - ...(record.env && typeof record.env === 'object' && !Array.isArray(record.env) - ? { - env: Object.fromEntries( - Object.entries(record.env).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ), - } - : {}), - ...(typeof record.cwd === 'string' && record.cwd.trim() ? { cwd: resolve(workspaceRoot, record.cwd) } : {}), - } -} - -async function resolveAvailableServer( - cwd: string, - server: ResolvedLspServerConfig, - filePath: string | undefined, - allowMarkerlessExplicit: boolean, - useLspmux: boolean, -): Promise { - if (filePath && !supportsLspFile({ fileTypes: server.fileTypes ?? [] }, filePath)) return undefined - const workspaceRoot = await findLspWorkspaceRoot(cwd, filePath, server.rootMarkers ?? []) - if (!workspaceRoot && !allowMarkerlessExplicit) return undefined - const root = workspaceRoot ?? resolve(server.cwd ?? cwd) - const command = await resolveLspExecutable(server.command, root, server.cwd) - if (!command) return undefined - const wrapped = await wrapRustAnalyzerWithLspmux({ - command, - args: server.args, - cwd: server.cwd ?? root, - enabled: useLspmux, - }) - const shim = wrapWindowsCommandShim(wrapped.command, wrapped.args) - return { - ...server, - command: shim?.command ?? wrapped.command, - args: shim?.args ?? wrapped.args, - cwd: server.cwd ?? root, - // Merge instead of replace so configured env survives mux wrapping (#380). - ...(server.env || wrapped.env ? { env: { ...server.env, ...wrapped.env } } : {}), - lspmux: wrapped.lspmux, - } -} - -function sortServers(servers: ResolvedLspServerConfig[]): ResolvedLspServerConfig[] { - return servers.sort((left, right) => - (right.priority ?? 0) - (left.priority ?? 0) - || (left.role === 'linter' ? 1 : 0) - (right.role === 'linter' ? 1 : 0) - || (left.name ?? '').localeCompare(right.name ?? '') - ) -} - -export async function getLspClientsForFile(cwd: string, config?: Record, filePath?: string): Promise { - ensureIdleChecker() - const servers = await resolveLspServerConfigsForFile(cwd, config, filePath) - const results = await Promise.all(servers.map(async (server) => { - const workspaceRoot = server.cwd ?? resolve(cwd) - const runtimeServer = server - const key = serverKey(workspaceRoot, runtimeServer) - const failed = failedStarts.get(key) - if (failed && failed.until > Date.now()) return undefined - if (failed) failedStarts.delete(key) - let client = clients.get(key) - if (!client) { - client = LspClient.start(workspaceRoot, runtimeServer, () => { - if (clients.get(key) === client) clients.delete(key) - }) - clients.set(key, client) - client.catch((error) => { - clients.delete(key) - if (!isTransientLspFailure(error)) { - failedStarts.set(key, { until: Date.now() + 30_000, error: error instanceof Error ? error : new Error(String(error)) }) - } - }) - } - try { - return await client - } catch { - return undefined - } - })) - const ready = results.filter((client): client is LspClient => Boolean(client)) - if (ready.length === 0) throw new Error(`Unable to start any configured LSP server for ${filePath ?? cwd}`) - return ready -} - -function isTransientLspFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /timed out|aborted|cancelled/i.test(message) -} - -// child_process cannot spawn .cmd/.bat shims directly on Windows (EINVAL), so -// resolveAvailableServer routes them through cmd.exe. -// ponytail: arguments beyond the command path are not cmd-escaped (quotes or -// spaces would trip cmd's /s quote stripping); LSP server args never carry -// embedded quotes. -function wrapWindowsCommandShim(command: string, args: string[]): { command: string; args: string[] } | undefined { - if (process.platform !== 'win32') return undefined - if (!/\.(cmd|bat)$/i.test(command)) return undefined - return { command: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', command, ...args] } -} - -// Killing a cmd.exe shim wrapper alone orphans the real server behind it, so -// the whole tree goes down on Windows. spawnSync keeps the kill synchronous -// with dispose instead of racing process-exit observers. -function killLspProcessTree(child: ChildProcessWithoutNullStreams): void { - if (process.platform === 'win32' && child.pid !== undefined) { - try { - spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) - return - } catch { - // Fall through to a direct kill if taskkill is unavailable. - } - } - child.kill() -} - -export async function getLspClient(cwd: string, config?: Record, filePath?: string): Promise { - const client = (await getLspClientsForFile(cwd, config, filePath))[0] - if (!client) throw new Error('No LSP server configured') - return client -} - -export async function requestLspClients( - clientsForRequest: LspClient[], - method: string, - params: unknown, - timeoutMs?: number, - signal?: AbortSignal, -): Promise> { - const results = await Promise.all(clientsForRequest.map(async (client) => { - try { - return { server: client.serverName, result: await client.request(method, params, timeoutMs, signal) as T } - } catch (error) { - if (signal?.aborted) throw error - return undefined - } - })) - return results.filter((value): value is { server: string; result: T } => Boolean(value)) -} - -export async function collectLspDiagnostics( - clientsForRequest: LspClient[], - filePath: string, - timeoutMs = 3_000, - signal?: AbortSignal, -): Promise { - const results = await Promise.all(clientsForRequest.map(async (client) => { - try { - const diagnostics = await client.waitForDiagnostics(filePath, timeoutMs, signal) - return diagnostics.map((diagnostic) => ({ ...diagnostic, server: client.serverName })) - } catch (error) { - if (signal?.aborted) throw error - return [] - } - })) - const seen = new Set() - return results.flat().filter((diagnostic) => { - const key = [diagnostic.range.start.line, diagnostic.range.start.character, diagnostic.range.end.line, diagnostic.range.end.character, diagnostic.severity ?? '', diagnostic.code ?? '', diagnostic.message].join('|') - if (seen.has(key)) return false - seen.add(key) - return true - }) -} - -export async function notifyLspFileChanged(filePath: string): Promise { - const absolutePath = resolve(filePath) - await Promise.all([...clients.values()].map(async (pending) => { - const client = await pending.catch(() => undefined) - if (!client || !client.ownsPath(absolutePath)) return - try { - await client.syncFile(absolutePath) - await client.notifySaved(absolutePath) - } catch { - // File writes must remain successful even when a language server is wedged. - } - })) -} - -export async function notifyLspFileClosed(filePath: string): Promise { - const absolutePath = resolve(filePath) - await Promise.all([...clients.values()].map(async (pending) => { - const client = await pending.catch(() => undefined) - if (!client || !client.ownsPath(absolutePath)) return - await client.closeFile(absolutePath).catch(() => undefined) - })) -} - -export async function notifyLspWatchedFiles(changes: LspWatchedFileChange[]): Promise { - await Promise.all([...clients.values()].map(async (pending) => { - const client = await pending.catch(() => undefined) - if (!client) return - await client.notifyWatchedFiles(changes).catch(() => undefined) - })) -} - -export async function shutdownLspClients(cwd?: string): Promise { - const normalizedCwd = cwd ? resolve(cwd) : undefined - const entries = [...clients.entries()] - await Promise.all(entries.map(async ([key, pending]) => { - const client = await pending.catch(() => undefined) - if (!client || (normalizedCwd && resolve(client.cwd) !== normalizedCwd)) return - clients.delete(key) - await client.dispose() - })) - if (!cwd) { - failedStarts.clear() - resolutionCache.clear() - } -} - -export async function warmupLspClients(cwd: string, config?: Record, timeoutMs = 5_000): Promise { - const boundedTimeout = Math.min(Math.max(timeoutMs, 1), 5_000) - let timer: ReturnType | undefined - try { - await Promise.race([ - getLspClientsForFile(cwd, config).then(() => undefined).catch(() => undefined), - new Promise((resolve) => { timer = setTimeout(resolve, boundedTimeout) }), - ]) - } finally { - if (timer) clearTimeout(timer) - } -} - -export class LspClient { - private constructor( - readonly cwd: string, - private readonly server: ResolvedLspServerConfig, - private readonly process: ChildProcessWithoutNullStreams, - private readonly onDead: () => void, - ) { - this.exited = new Promise((resolve) => this.process.once('exit', () => resolve())) - this.process.stdout.on('data', (chunk: Buffer) => this.onOutput(chunk)) - this.process.stderr.on('data', (chunk: Buffer) => { - this.stderrTail = `${this.stderrTail}${chunk.toString('utf8')}`.slice(-4_000) - }) - this.process.on('error', (error) => this.fail(error)) - this.process.on('exit', (code, signal) => this.fail(new Error( - `LSP server exited (${code ?? signal ?? 'unknown'})${this.stderrTail.trim() ? `: ${this.stderrTail.trim()}` : ''}`, - ))) - } - - private nextId = 1 - private outputBuffer: Buffer = Buffer.alloc(0) - private readonly pending = new Map void; reject: (error: Error) => void }>() - private readonly documents = new Map() - private readonly diagnostics = new Map() - private readonly dynamicCapabilities = new Map() - private writeQueue = Promise.resolve() - private diagnosticsVersion = 0 - private lastActivity = Date.now() - private serverCapabilities: LspServerCapabilities = {} - private stderrTail = '' - private dead = false - private readonly exited: Promise - private initialized = false - private disposed = false - - get serverName(): string { return this.server.name ?? 'default' } - get serverRole(): LspServerRole { return this.server.role ?? 'primary' } - - get state(): LspClientState { - if (this.disposed) return 'disposed' - if (this.dead) return 'failed' - if (!this.initialized) return 'initializing' - return 'ready' - } - - static async start(cwd: string, server: ResolvedLspServerConfig, onDead = () => undefined): Promise { - // #380:LSP 命令可来自项目内 lsp.json(readProjectLspServers),全量 - // process.env 会向项目可控进程泄漏 API key/token——与 MCP stdio 路径对齐, - // 走官方最小环境集 + server.env 显式合并 - const { getDefaultEnvironment } = await import('@modelcontextprotocol/sdk/client/stdio.js') - const child = spawn(server.command, server.args, { - cwd: server.cwd ?? cwd, - env: { ...getDefaultEnvironment(), ...server.env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }) - const client = new LspClient(cwd, server, child, onDead) - try { - await client.initialize() - } catch (error) { - killLspProcessTree(child) - // A failed mux'd start must not keep serving a stale "running" probe; - // invalidate so the next resolution falls back to a direct connection (#374). - if (server.lspmux) invalidateLspmuxCache(resolve(server.cwd ?? cwd)) - const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Unable to start LSP server "${server.command}": ${detail}`) - } - return client - } - - ownsPath(filePath: string): boolean { - const root = resolve(this.cwd) - const absolute = resolve(filePath) - return absolute === root || absolute.startsWith(`${root}/`) || absolute.startsWith(`${root}\\`) - } - - isIdle(timeoutMs: number): boolean { - return Date.now() - this.lastActivity >= timeoutMs - } - - async syncFile(filePath: string): Promise { - if (!this.ownsPath(filePath)) return - const content = await readFile(filePath, 'utf8') - await this.syncContent(filePath, content) - } - - async syncContent(filePath: string, content: string): Promise { - if (!this.ownsPath(filePath)) return 0 - const uri = fileUri(filePath) - let nextVersion = 0 - await this.withDocumentLock(uri, async () => { - this.diagnostics.delete(uri) - const current = this.documents.get(uri) - if (!current) { - this.documents.set(uri, { version: 1, content }) - nextVersion = 1 - await this.notify('textDocument/didOpen', { - textDocument: { uri, languageId: languageIdForPath(filePath), version: 1, text: content }, - }) - return - } - const version = current.version + 1 - this.documents.set(uri, { version, content }) - nextVersion = version - await this.notify('textDocument/didChange', { - textDocument: { uri, version }, - contentChanges: [{ text: content }], - }) - }) - return nextVersion - } - - async notifySaved(filePath: string): Promise { - const uri = fileUri(filePath) - if (!this.documents.has(uri)) return - await this.notify('textDocument/didSave', { textDocument: { uri } }) - } - - async closeFile(filePath: string): Promise { - const uri = fileUri(filePath) - const prefix = uri.endsWith('/') ? uri : `${uri}/` - const targets = [...this.documents.keys()].filter((candidate) => candidate === uri || candidate.startsWith(prefix)) - for (const target of targets) { - await this.notify('textDocument/didClose', { textDocument: { uri: target } }) - this.documents.delete(target) - this.diagnostics.delete(target) - } - } - - async notifyWatchedFiles(changes: LspWatchedFileChange[]): Promise { - await this.notify('workspace/didChangeWatchedFiles', { changes }) - } - - async notifyRenamedFiles(files: Array<{ oldUri: string; newUri: string }>): Promise { - await this.notify('workspace/didRenameFiles', { files }) - } - - async request(method: string, params: unknown, timeoutMs = this.server.requestTimeoutMs ?? 20_000, signal?: AbortSignal): Promise { - timeoutMs = Math.min(Math.max(timeoutMs, 1), 300_000) - const id = this.nextId++ - const result = new Promise((resolve, reject) => { - this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }) - void this.send({ jsonrpc: '2.0', id, method, params }).catch(reject) - }) - let timeout: ReturnType | undefined - let abortHandler: (() => void) | undefined - try { - return await Promise.race([ - result, - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(`LSP request timed out: ${method}`)), timeoutMs) - if (signal) { - abortHandler = () => reject(signal.reason instanceof Error ? signal.reason : new Error('LSP request aborted')) - if (signal.aborted) abortHandler() - else signal.addEventListener('abort', abortHandler, { once: true }) - } - }), - ]) - } finally { - if (timeout) clearTimeout(timeout) - if (abortHandler && signal) signal.removeEventListener('abort', abortHandler) - this.pending.delete(id) - } - } - - getDiagnostics(filePath: string): LspDiagnostic[] { - return this.diagnostics.get(fileUri(filePath))?.diagnostics ?? [] - } - - getDocumentVersion(filePath: string): number { - return this.documents.get(fileUri(filePath))?.version ?? 0 - } - - getDiagnosticsSequence(): number { - return this.diagnosticsVersion - } - - async waitForDiagnostics( - filePath: string, - timeoutMs = 3_000, - signal?: AbortSignal, - expectedDocumentVersion?: number, - afterSequence = 0, - ): Promise { - const uri = fileUri(filePath) - const alreadyPublished = this.diagnostics.get(uri) - if (alreadyPublished && isFreshDiagnostics(alreadyPublished, expectedDocumentVersion, afterSequence)) { - return alreadyPublished.diagnostics - } - if (this.supportsDocumentDiagnostics()) { - try { - const pulled = await this.request<{ items?: LspDiagnostic[] }>('textDocument/diagnostic', { - textDocument: { uri }, - }, timeoutMs, signal) - const diagnostics = pulled?.items ?? [] - this.diagnosticsVersion += 1 - this.diagnostics.set(uri, { - diagnostics, - version: expectedDocumentVersion, - publishedAt: Date.now(), - sequence: this.diagnosticsVersion, - }) - return diagnostics - } catch { - // Fall back to publishDiagnostics for older servers. - } - } - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('LSP diagnostics aborted') - const published = this.diagnostics.get(uri) - if (published && isFreshDiagnostics(published, expectedDocumentVersion, afterSequence)) { - if (published.version !== undefined || Date.now() - published.publishedAt >= 250) return published.diagnostics - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - return [] - } - - getStatus(): LspServerStatus { - return { - server: this.serverName, - status: this.process.exitCode === null ? 'ready' : 'error', - state: this.state, - cwd: this.cwd, - capabilities: this.serverCapabilities, - openFiles: this.documents.size, - diagnosticsVersion: this.diagnosticsVersion, - lastActivity: new Date(this.lastActivity).toISOString(), - ...(this.server.lspmux ? { lspmux: true } : {}), - } - } - - supportsDocumentDiagnostics(): boolean { - return Boolean(this.serverCapabilities.diagnosticProvider || [...this.dynamicCapabilities.values()].includes('textDocument/diagnostic')) - } - - async reload(): Promise { - resolutionCache.clear() - await this.dispose() - } - - async dispose(): Promise { - if (this.disposed) return - this.disposed = true - try { - if (this.initialized && this.process.exitCode === null) { - await this.request('shutdown', null, 1_000).catch(() => undefined) - await this.notify('exit', null).catch(() => undefined) - await Promise.race([this.exited, new Promise((resolve) => setTimeout(resolve, 500))]) - } - } catch { - // The process is still forcibly terminated below. - } finally { - if (this.process.exitCode === null) killLspProcessTree(this.process) - this.fail(new Error('LSP client disposed')) - await Promise.race([this.exited, new Promise((resolve) => setTimeout(resolve, 250))]) - } - } - - private async initialize(): Promise { - const result = await this.request('initialize', { - processId: process.pid, - rootPath: this.cwd, - rootUri: fileUri(this.cwd), - capabilities: { - workspace: { - workspaceFolders: true, - applyEdit: true, - configuration: true, - didChangeWatchedFiles: { dynamicRegistration: true }, - fileOperations: { - dynamicRegistration: true, - willRename: true, - didRename: true, - }, - diagnostics: { refreshSupport: true }, - }, - textDocument: { - synchronization: { dynamicRegistration: false, willSave: false, didSave: true, willSaveWaitUntil: false }, - definition: { linkSupport: true }, - implementation: { linkSupport: true }, - references: {}, - hover: { contentFormat: ['markdown', 'plaintext'] }, - documentSymbol: { hierarchicalDocumentSymbolSupport: true }, - rename: { prepareSupport: true }, - typeDefinition: { linkSupport: true }, - codeAction: { - codeActionLiteralSupport: { - codeActionKind: { - valueSet: ['quickfix', 'refactor', 'source', 'source.organizeImports', 'source.fixAll'], - }, - }, - resolveSupport: { properties: ['edit'] }, - }, - formatting: {}, - rangeFormatting: {}, - callHierarchy: { dynamicRegistration: false }, - publishDiagnostics: {}, - }, - }, - workspaceFolders: [{ uri: fileUri(this.cwd), name: resolve(this.cwd).split(/[\\/]/).pop() || 'workspace' }], - initializationOptions: this.server.initOptions ?? {}, - }, this.server.requestTimeoutMs ?? 20_000) - this.initialized = true - this.serverCapabilities = result?.capabilities ?? {} - await this.notify('initialized', {}) - await this.notify('workspace/didChangeConfiguration', { settings: this.server.settings ?? {} }) - } - - private async notify(method: string, params: unknown): Promise { - if (!this.initialized && method !== 'initialized') return - await this.send({ jsonrpc: '2.0', method, params }) - } - - private send(message: unknown): Promise { - this.lastActivity = Date.now() - const write = this.writeQueue.catch(() => undefined).then(() => { - if (!this.process.stdin.writable) throw new Error('LSP server stdin is not writable') - return new Promise((resolve, reject) => { - this.process.stdin.write(encodeLspMessage(message), (error) => error ? reject(error) : resolve()) - }) - }) - this.writeQueue = write.catch(() => undefined) - // Every notification path funnels through here, so one timeout + fail() - // covers them all: a wedged pipe marks the client dead and routes every - // caller into the existing restart chain instead of hanging forever (#327). - let timer: ReturnType | undefined - return Promise.race([ - write, - new Promise((_, reject) => { - // No unref here: bun's test runner never fires unref'd timers, and - // this one is always released by the settle handlers below anyway. - timer = setTimeout(() => reject(new Error(`LSP server write timed out after ${writeTimeoutMs}ms`)), writeTimeoutMs) - }), - ]).then( - (value) => { - clearTimeout(timer) - return value - }, - (error) => { - clearTimeout(timer) - this.fail(error instanceof Error ? error : new Error(String(error))) - throw error - }, - ) - } - - private onOutput(chunk: Buffer): void { - try { - const parsed = parseLspMessages(chunk, this.outputBuffer) - this.outputBuffer = parsed.rest - for (const message of parsed.messages) this.onMessage(message) - } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))) - } - } - - private onMessage(message: unknown): void { - if (!message || typeof message !== 'object') return - const value = message as Record - if (typeof value.method === 'string') { - if (typeof value.id === 'number' || typeof value.id === 'string') { - void this.handleServerRequest(value.method, value.id, value.params) - return - } - if (value.method === 'textDocument/publishDiagnostics') { - const params = value.params as { uri?: string; diagnostics?: LspDiagnostic[]; version?: number | null } | undefined - if (params?.uri) { - this.diagnosticsVersion += 1 - this.diagnostics.set(params.uri, { - diagnostics: params.diagnostics ?? [], - version: params.version, - publishedAt: Date.now(), - sequence: this.diagnosticsVersion, - }) - } - } - return - } - if (typeof value.id !== 'number' && typeof value.id !== 'string') return - const responseId = value.id - const response = value as unknown as JsonRpcResponse - const pending = this.pending.get(responseId) - if (!pending) return - this.pending.delete(responseId) - if (response.error) pending.reject(new Error(`${response.error.message} (${response.error.code})`)) - else pending.resolve(response.result) - } - - private async handleServerRequest(method: string, id: number | string, params: unknown): Promise { - if (method === 'workspace/workspaceFolders') { - await this.send({ jsonrpc: '2.0', id, result: [{ uri: fileUri(this.cwd), name: resolve(this.cwd).split(/[\\/]/).pop() || 'workspace' }] }) - return - } - if (method === 'workspace/configuration') { - const items = Array.isArray((params as { items?: unknown[] } | undefined)?.items) - ? (params as { items: Array<{ section?: unknown }> }).items.map((item) => - configurationSection(this.server.settings ?? {}, typeof item?.section === 'string' ? item.section : undefined) - ) - : [] - await this.send({ jsonrpc: '2.0', id, result: items }) - return - } - if (method === 'client/registerCapability') { - const registrations = (params as { registrations?: Array<{ id?: string; method?: string }> } | undefined)?.registrations ?? [] - for (const registration of registrations) { - if (registration.id && registration.method) this.dynamicCapabilities.set(registration.id, registration.method) - } - await this.send({ jsonrpc: '2.0', id, result: null }) - return - } - if (method === 'client/unregisterCapability') { - const unregisterations = (params as { unregisterations?: Array<{ id?: string }>; unregistrations?: Array<{ id?: string }> } | undefined) - const registrations = unregisterations?.unregisterations ?? unregisterations?.unregistrations ?? [] - for (const registration of registrations) if (registration.id) this.dynamicCapabilities.delete(registration.id) - await this.send({ jsonrpc: '2.0', id, result: null }) - return - } - if (method === 'window/showMessageRequest') { - await this.send({ jsonrpc: '2.0', id, result: null }) - return - } - if (method === 'window/showDocument') { - await this.send({ jsonrpc: '2.0', id, result: { success: false } }) - return - } - if (method === 'workspace/applyEdit') { - await this.send({ - jsonrpc: '2.0', - id, - result: { applied: false, failureReason: 'Headless Lume applies WorkspaceEdit through the LSP tool with explicit permission.' }, - }) - return - } - if (method === 'window/workDoneProgress/create' || method.startsWith('workspace/') || method.startsWith('$/')) { - await this.send({ jsonrpc: '2.0', id, result: null }) - return - } - await this.send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }) - } - - private async withDocumentLock(uri: string, operation: () => Promise): Promise { - // Publish the new tail before awaiting the previous lock, otherwise every - // waiter observes the same head and the chain stops serializing (#221). - const previous = clientLocks.get(uri) ?? Promise.resolve() - let release!: () => void - const lock = new Promise((resolve) => { release = resolve }) - clientLocks.set(uri, lock) - await previous.catch(() => undefined) - try { return await operation() } finally { - release() - if (clientLocks.get(uri) === lock) clientLocks.delete(uri) - } - } - - private fail(error: Error): void { - if (this.dead) return - this.dead = true - for (const { reject } of this.pending.values()) reject(error) - this.pending.clear() - this.initialized = false - this.onDead() - } -} - -function isFreshDiagnostics( - value: { version?: number | null; sequence: number }, - expectedDocumentVersion: number | undefined, - afterSequence: number, -): boolean { - if (value.sequence <= afterSequence) return false - if (expectedDocumentVersion === undefined || value.version === undefined || value.version === null) return true - return value.version >= expectedDocumentVersion -} - -function configurationSection(settings: Record, section: string | undefined): unknown { - if (!section) return settings - if (Object.prototype.hasOwnProperty.call(settings, section)) return settings[section] - let current: unknown = settings - for (const key of section.split('.')) { - if (!current || typeof current !== 'object' || Array.isArray(current)) return null - current = (current as Record)[key] - } - return current ?? null -} - -export function languageIdForPath(filePath: string): string { - const lower = filePath.toLowerCase() - const name = lower.split(/[\\/]/).pop() ?? lower - if (lower.endsWith('.tsx')) return 'typescriptreact' - if (lower.endsWith('.jsx')) return 'javascriptreact' - if (lower.endsWith('.ts')) return 'typescript' - if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) return 'javascript' - if (lower.endsWith('.json') || lower.endsWith('.jsonc')) return 'json' - if (lower.endsWith('.rs')) return 'rust' - if (lower.endsWith('.go')) return 'go' - if (lower.endsWith('.py') || lower.endsWith('.pyi')) return 'python' - if (lower.endsWith('.java')) return 'java' - if (lower.endsWith('.kt') || lower.endsWith('.kts')) return 'kotlin' - if (lower.endsWith('.scala') || lower.endsWith('.sbt') || lower.endsWith('.sc')) return 'scala' - if (/\.(c|h)$/.test(lower)) return 'c' - if (/\.(cpp|cc|cxx|hpp|hxx|m|mm)$/.test(lower)) return 'cpp' - if (lower.endsWith('.cs') || lower.endsWith('.csx')) return 'csharp' - if (/\.(rb|rake|gemspec)$/.test(lower)) return 'ruby' - if (lower.endsWith('.php') || lower.endsWith('.phtml')) return 'php' - if (lower.endsWith('.swift')) return 'swift' - if (lower.endsWith('.dart')) return 'dart' - if (lower.endsWith('.lua')) return 'lua' - if (lower.endsWith('.zig')) return 'zig' - if (/\.(ex|exs|heex|eex)$/.test(lower)) return 'elixir' - if (lower.endsWith('.hs') || lower.endsWith('.lhs')) return 'haskell' - if (/\.(ml|mli|mll|mly)$/.test(lower)) return 'ocaml' - if (lower.endsWith('.erl') || lower.endsWith('.hrl')) return 'erlang' - if (lower.endsWith('.gleam')) return 'gleam' - if (/\.(sh|bash|zsh)$/.test(lower)) return 'shellscript' - if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml' - if (lower.endsWith('.css')) return 'css' - if (lower.endsWith('.scss')) return 'scss' - if (lower.endsWith('.sass')) return 'sass' - if (lower.endsWith('.less')) return 'less' - if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'html' - if (lower.endsWith('.vue')) return 'vue' - if (lower.endsWith('.svelte')) return 'svelte' - if (lower.endsWith('.astro')) return 'astro' - if (lower.endsWith('.tf') || lower.endsWith('.tfvars')) return 'terraform' - if (name === 'dockerfile' || lower.endsWith('.dockerfile')) return 'dockerfile' - if (lower.endsWith('.nix')) return 'nix' - if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown' - if (/\.(tex|bib|sty|cls)$/.test(lower)) return 'latex' - if (lower.endsWith('.graphql') || lower.endsWith('.gql')) return 'graphql' - if (lower.endsWith('.prisma')) return 'prisma' - if (lower.endsWith('.vim') || name === '.vimrc') return 'vim' - if (lower.endsWith('.odin')) return 'odin' - if (lower.endsWith('.tla') || lower.endsWith('.tlaplus')) return 'tlaplus' - return 'plaintext' -} diff --git a/packages/sdk/src/lsp/lspmux.test.ts b/packages/sdk/src/lsp/lspmux.test.ts deleted file mode 100644 index 42f3ddc24..000000000 --- a/packages/sdk/src/lsp/lspmux.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, describe, expect, test } from 'bun:test' -import { EventEmitter } from 'node:events' -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { invalidateLspmuxCache, setLspmuxCacheTtls, setLspmuxProbeSpawn, wrapRustAnalyzerWithLspmux } from './lspmux.js' - -const spawnCalls: Array<{ command: string; args: string[] }> = [] -let nextExitCode: number | null = 0 -let nextSpawnError: Error | undefined - -class FakeChild extends EventEmitter { - kill = () => true -} - -function fakeSpawn(command: string, args: string[]): FakeChild { - spawnCalls.push({ command, args }) - const child = new FakeChild() as any - queueMicrotask(() => { - if (nextSpawnError) child.emit('error', nextSpawnError) - else child.emit('exit', nextExitCode) - }) - return child -} - -// Injected through the module seam: mocking node:child_process itself would -// pollute every other suite sharing bun's test process. -setLspmuxProbeSpawn(fakeSpawn as any) - -afterEach(() => { - nextExitCode = 0 - nextSpawnError = undefined -}) - -async function makeWorkspace(): Promise { - const root = await mkdtemp(join(tmpdir(), 'lume-lspmux-')) - // A plain file named `lspmux` resolves as the daemon executable; spawn is - // mocked, so no real binary is needed. - const shim = join(root, 'lspmux') - await writeFile(shim, '') - if (process.platform !== 'win32') await chmod(shim, 0o755) - return root -} - -function wrap(root: string) { - return wrapRustAnalyzerWithLspmux({ - command: join(root, 'rust-analyzer'), - args: [], - cwd: root, - enabled: true, - }) -} - -describe('lspmux detection cache (#374)', () => { - test('wraps rust-analyzer when the daemon answers and caches positive probes per cwd', async () => { - const root = await makeWorkspace() - try { - setLspmuxCacheTtls({ positiveMs: 60, negativeMs: 120 }) - nextExitCode = 0 - spawnCalls.length = 0 - invalidateLspmuxCache() - - const wrapped = await wrap(root) - expect(wrapped.lspmux).toBe(true) - expect(wrapped.args).toEqual(['client']) - expect(wrapped.env?.LSPMUX_SERVER).toBe(join(root, 'rust-analyzer')) - expect(spawnCalls).toHaveLength(1) - - // Inside the positive TTL the cached "running" answer is reused. - await wrap(root) - expect(spawnCalls).toHaveLength(1) - } finally { - setLspmuxCacheTtls({ positiveMs: 30_000, negativeMs: 300_000 }) - invalidateLspmuxCache() - await rm(root, { recursive: true, force: true }) - } - }, 10_000) - - test('re-probes once the positive TTL lapses', async () => { - const root = await makeWorkspace() - try { - setLspmuxCacheTtls({ positiveMs: 40, negativeMs: 120_000 }) - nextExitCode = 0 - spawnCalls.length = 0 - invalidateLspmuxCache() - - await wrap(root) - expect(spawnCalls).toHaveLength(1) - await new Promise((resolve) => setTimeout(resolve, 70)) - await wrap(root) - expect(spawnCalls).toHaveLength(2) - } finally { - setLspmuxCacheTtls({ positiveMs: 30_000, negativeMs: 300_000 }) - invalidateLspmuxCache() - await rm(root, { recursive: true, force: true }) - } - }, 10_000) - - test('caches negative results for the longer window and falls back to a direct connection', async () => { - const root = await makeWorkspace() - try { - setLspmuxCacheTtls({ positiveMs: 40, negativeMs: 150 }) - nextExitCode = 1 - spawnCalls.length = 0 - invalidateLspmuxCache() - - const direct = await wrap(root) - expect(direct.lspmux).toBe(false) - expect(direct.command).toBe(join(root, 'rust-analyzer')) - expect(spawnCalls).toHaveLength(1) - - // Still inside the negative TTL: no new probe. - await wrap(root) - expect(spawnCalls).toHaveLength(1) - - await new Promise((resolve) => setTimeout(resolve, 180)) - await wrap(root) - expect(spawnCalls).toHaveLength(2) - } finally { - setLspmuxCacheTtls({ positiveMs: 30_000, negativeMs: 300_000 }) - invalidateLspmuxCache() - await rm(root, { recursive: true, force: true }) - } - }, 10_000) - - test('invalidation forces an immediate re-probe and cwd keys stay independent', async () => { - const rootA = await makeWorkspace() - const rootB = await makeWorkspace() - try { - setLspmuxCacheTtls({ positiveMs: 60_000, negativeMs: 300_000 }) - nextExitCode = 0 - spawnCalls.length = 0 - invalidateLspmuxCache() - - await wrap(rootA) - await wrap(rootB) - expect(spawnCalls).toHaveLength(2) - - // Only A's entry is dropped. - invalidateLspmuxCache(rootA) - await wrap(rootB) - expect(spawnCalls).toHaveLength(2) - await wrap(rootA) - expect(spawnCalls).toHaveLength(3) - } finally { - setLspmuxCacheTtls({ positiveMs: 30_000, negativeMs: 300_000 }) - invalidateLspmuxCache() - await rm(rootA, { recursive: true, force: true }) - await rm(rootB, { recursive: true, force: true }) - } - }, 10_000) - - test('a failing probe counts as not running', async () => { - const root = await makeWorkspace() - try { - setLspmuxCacheTtls({ positiveMs: 60_000, negativeMs: 300_000 }) - nextSpawnError = new Error('spawn failed') - spawnCalls.length = 0 - invalidateLspmuxCache() - - const wrapped = await wrap(root) - expect(wrapped.lspmux).toBe(false) - expect(spawnCalls).toHaveLength(1) - } finally { - nextSpawnError = undefined - setLspmuxCacheTtls({ positiveMs: 30_000, negativeMs: 300_000 }) - invalidateLspmuxCache() - await rm(root, { recursive: true, force: true }) - } - }, 10_000) -}) diff --git a/packages/sdk/src/lsp/lspmux.ts b/packages/sdk/src/lsp/lspmux.ts deleted file mode 100644 index 46eb8d514..000000000 --- a/packages/sdk/src/lsp/lspmux.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type ChildProcess, spawn } from 'node:child_process' -import { basename, resolve } from 'node:path' -import { resolveLspExecutable } from './registry.js' - -interface LspmuxProbe { - checkedAt: number - command?: string - running: boolean -} - -// Test seam so suites can inject a fake probe process instead of mocking the -// whole node:child_process module (which pollutes every other test in the -// shared bun test process). -type ProbeSpawn = ( - command: string, - args: string[], - options: { cwd: string } -) => Pick -const defaultProbeSpawn: ProbeSpawn = (command, args, options) => - spawn(command, args, { cwd: options.cwd, windowsHide: true, stdio: 'ignore' }) -let probeSpawn: ProbeSpawn = defaultProbeSpawn - -export function setLspmuxProbeSpawn(impl: ProbeSpawn | undefined): void { - probeSpawn = impl ?? defaultProbeSpawn -} - -// Positive results expire quickly so a stopped daemon is picked up within -// seconds; negative results stick longer because every probe spawns a -// process. The cache is keyed by cwd — probes are per-workspace, so one -// module-wide entry made daemons in other workspaces appear (or vanish) -// wrongly (#374). -let positiveTtlMs = 30_000 -let negativeTtlMs = 5 * 60_000 -const probes = new Map() - -export function setLspmuxCacheTtls(ttls: { positiveMs?: number; negativeMs?: number }): void { - if (ttls.positiveMs !== undefined) positiveTtlMs = ttls.positiveMs - if (ttls.negativeMs !== undefined) negativeTtlMs = ttls.negativeMs -} - -export function invalidateLspmuxCache(cwd?: string): void { - if (cwd === undefined) { - probes.clear() - } else { - probes.delete(resolve(cwd)) - } -} - -export async function wrapRustAnalyzerWithLspmux(input: { - command: string - args: string[] - cwd: string - enabled: boolean -}): Promise<{ command: string; args: string[]; env?: Record; lspmux: boolean }> { - if (!input.enabled || basename(input.command).replace(/\.(exe|cmd|bat)$/i, '') !== 'rust-analyzer') { - return { command: input.command, args: input.args, lspmux: false } - } - const state = await detectLspmux(input.cwd) - if (!state.command || !state.running) return { command: input.command, args: input.args, lspmux: false } - return { - command: state.command, - args: input.args.length > 0 ? ['client', '--', ...input.args] : ['client'], - env: { LSPMUX_SERVER: input.command }, - lspmux: true, - } -} - -async function detectLspmux(cwd: string): Promise<{ command?: string; running: boolean }> { - const key = resolve(cwd) - const cached = probes.get(key) - if (cached && Date.now() - cached.checkedAt < (cached.running ? positiveTtlMs : negativeTtlMs)) return cached - const command = await resolveLspExecutable('lspmux', cwd) - if (!command) { - const next: LspmuxProbe = { checkedAt: Date.now(), running: false } - probes.set(key, next) - return next - } - const running = await new Promise((resolvePromise) => { - const child = probeSpawn(command, ['status'], { cwd }) - const timer = setTimeout(() => { - child.kill() - resolvePromise(false) - }, 1_000) - child.once('error', () => { - clearTimeout(timer) - resolvePromise(false) - }) - child.once('exit', (code) => { - clearTimeout(timer) - resolvePromise(code === 0) - }) - }) - const next: LspmuxProbe = { checkedAt: Date.now(), command, running } - probes.set(key, next) - return next -} diff --git a/packages/sdk/src/lsp/registry.ts b/packages/sdk/src/lsp/registry.ts deleted file mode 100644 index d26b4d86b..000000000 --- a/packages/sdk/src/lsp/registry.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Built-in LSP registry. - * - * Server names, commands, file types and root markers are derived from the - * Oh My Pi coding-agent registry (MIT): - * Copyright 2025 Mario Zechner - * Copyright 2025-2026 Can Bölük - */ - -import { access, readdir } from 'node:fs/promises' -import { constants, existsSync } from 'node:fs' -import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path' - -export type LspServerRole = 'primary' | 'linter' - -export interface LspRegistryServer { - command: string - args: string[] - fileTypes: string[] - rootMarkers: string[] - initOptions?: Record - settings?: Record - role?: LspServerRole - priority?: number - warmupTimeoutMs?: number - adapter?: 'swiftlint' -} - -const server = ( - command: string, - args: string[], - fileTypes: string[], - rootMarkers: string[], - role: LspServerRole = 'primary', -): LspRegistryServer => ({ command, args, fileTypes, rootMarkers, initOptions: {}, settings: {}, role }) - -export const DEFAULT_LSP_SERVERS: Readonly> = { - 'rust-analyzer': { - ...server('rust-analyzer', [], ['.rs'], ['Cargo.toml', 'rust-analyzer.toml']), - settings: { 'rust-analyzer': { checkOnSave: false } }, - }, - tlaplus: server('tlapm_lsp', ['--stdio'], ['.tla', '.tlaplus'], ['*.tla']), - clangd: server('clangd', ['--background-index', '--clang-tidy', '--header-insertion=iwyu'], ['.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.m', '.mm'], ['compile_commands.json', 'CMakeLists.txt', '.clangd', '.clang-format', 'Makefile']), - zls: server('zls', [], ['.zig'], ['build.zig', 'build.zig.zon', 'zls.json']), - gopls: { - ...server('gopls', ['serve'], ['.go', '.mod', '.sum'], ['go.mod', 'go.work', 'go.sum']), - settings: { gopls: { analyses: { unusedparams: true, shadow: true }, staticcheck: true, gofumpt: true } }, - }, - 'typescript-language-server': { - ...server('typescript-language-server', ['--stdio'], ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], ['package.json', 'tsconfig.json', 'jsconfig.json']), - initOptions: { - hostInfo: 'lume', - preferences: { - includeInlayParameterNameHints: 'all', - includeInlayVariableTypeHints: true, - includeInlayFunctionParameterTypeHints: true, - }, - }, - }, - biome: server('biome', ['lsp-proxy'], ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonc'], ['biome.json', 'biome.jsonc'], 'linter'), - eslint: { - ...server('vscode-eslint-language-server', ['--stdio'], ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'], ['.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml', 'eslint.config.js', 'eslint.config.mjs'], 'linter'), - settings: { validate: 'on', run: 'onType' }, - }, - denols: { - ...server('deno', ['lsp'], ['.ts', '.tsx', '.js', '.jsx'], ['deno.json', 'deno.jsonc', 'deno.lock']), - initOptions: { enable: true, lint: true, unstable: true }, - }, - 'vscode-html-language-server': { - ...server('vscode-html-language-server', ['--stdio'], ['.html', '.htm'], ['package.json', '.git']), - initOptions: { provideFormatter: true }, - }, - 'vscode-css-language-server': { - ...server('vscode-css-language-server', ['--stdio'], ['.css', '.scss', '.sass', '.less'], ['package.json', '.git']), - initOptions: { provideFormatter: true }, - }, - 'vscode-json-language-server': { - ...server('vscode-json-language-server', ['--stdio'], ['.json', '.jsonc'], ['package.json', '.git']), - initOptions: { provideFormatter: true }, - }, - tailwindcss: server('tailwindcss-language-server', ['--stdio'], ['.html', '.css', '.scss', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte'], ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs', 'tailwind.config.cjs']), - svelte: server('svelteserver', ['--stdio'], ['.svelte'], ['svelte.config.js', 'svelte.config.mjs', 'package.json']), - 'vue-language-server': server('vue-language-server', ['--stdio'], ['.vue'], ['vue.config.js', 'nuxt.config.js', 'nuxt.config.ts', 'package.json']), - astro: server('astro-ls', ['--stdio'], ['.astro'], ['astro.config.mjs', 'astro.config.js', 'astro.config.ts']), - pyright: { - ...server('pyright-langserver', ['--stdio'], ['.py', '.pyi'], ['pyproject.toml', 'pyrightconfig.json', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile']), - settings: { python: { analysis: { autoSearchPaths: true, diagnosticMode: 'openFilesOnly', useLibraryCodeForTypes: true } } }, - }, - basedpyright: { - ...server('basedpyright-langserver', ['--stdio'], ['.py', '.pyi'], ['pyproject.toml', 'pyrightconfig.json', 'setup.py', 'requirements.txt']), - settings: { basedpyright: { analysis: { autoSearchPaths: true, diagnosticMode: 'openFilesOnly', useLibraryCodeForTypes: true } } }, - }, - pylsp: server('pylsp', [], ['.py'], ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile']), - ruff: server('ruff', ['server'], ['.py', '.pyi'], ['pyproject.toml', 'ruff.toml', '.ruff.toml'], 'linter'), - jdtls: server('jdtls', [], ['.java'], ['pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', '.project']), - 'kotlin-lsp': server('kotlin-lsp', ['--stdio'], ['.kt', '.kts'], ['build.gradle', 'build.gradle.kts', 'pom.xml', 'settings.gradle', 'settings.gradle.kts']), - metals: { - ...server('metals', [], ['.scala', '.sbt', '.sc'], ['build.sbt', 'build.sc', 'build.gradle', 'pom.xml']), - initOptions: { statusBarProvider: 'show-message', isHttpEnabled: true }, - }, - hls: { - ...server('haskell-language-server-wrapper', ['--lsp'], ['.hs', '.lhs'], ['stack.yaml', 'cabal.project', 'hie.yaml', 'package.yaml', '*.cabal']), - settings: { haskell: { formattingProvider: 'ormolu', checkProject: true } }, - }, - ocamllsp: server('ocamllsp', [], ['.ml', '.mli', '.mll', '.mly'], ['dune-project', 'dune-workspace', '*.opam', '.ocamlformat']), - elixirls: { - ...server('elixir-ls', [], ['.ex', '.exs', '.heex', '.eex'], ['mix.exs', 'mix.lock']), - settings: { elixirLS: { dialyzerEnabled: true, fetchDeps: false } }, - }, - expert: server('expert', ['--stdio'], ['.ex', '.exs', '.heex', '.eex'], ['mix.exs', 'mix.lock']), - erlangls: server('erlang_ls', [], ['.erl', '.hrl'], ['rebar.config', 'erlang.mk', 'rebar.lock']), - gleam: server('gleam', ['lsp'], ['.gleam'], ['gleam.toml']), - solargraph: { - ...server('solargraph', ['stdio'], ['.rb', '.rake', '.gemspec'], ['Gemfile', '.solargraph.yml', 'Rakefile']), - initOptions: { formatting: true }, - settings: { solargraph: { diagnostics: true, completion: true, hover: true, formatting: true, references: true, rename: true, symbols: true } }, - }, - 'ruby-lsp': { - ...server('ruby-lsp', [], ['.rb', '.rake', '.gemspec', '.erb'], ['Gemfile', '.ruby-version', '.ruby-gemset']), - initOptions: { formatter: 'auto' }, - }, - rubocop: server('rubocop', ['--lsp'], ['.rb', '.rake'], ['.rubocop.yml', 'Gemfile'], 'linter'), - bashls: { - ...server('bash-language-server', ['start'], ['.sh', '.bash', '.zsh'], ['.git']), - settings: { bashIde: { globPattern: '*@(.sh|.inc|.bash|.command)' } }, - }, - 'lua-language-server': { - ...server('lua-language-server', [], ['.lua'], ['.luarc.json', '.luarc.jsonc', '.luacheckrc', '.stylua.toml', 'stylua.toml']), - settings: { Lua: { runtime: { version: 'LuaJIT' }, diagnostics: { globals: ['vim'] }, workspace: { checkThirdParty: false }, telemetry: { enable: false } } }, - }, - intelephense: server('intelephense', ['--stdio'], ['.php', '.phtml'], ['composer.json', 'composer.lock', '.git']), - phpactor: server('phpactor', ['language-server'], ['.php'], ['composer.json', '.phpactor.json', '.phpactor.yml']), - omnisharp: { - ...server('omnisharp', ['-z', '--hostPID', String(process.pid), '--encoding', 'utf-8', '--languageserver'], ['.cs', '.csx'], ['*.sln', '*.csproj', 'omnisharp.json', '.git']), - settings: { FormattingOptions: { EnableEditorConfigSupport: true }, RoslynExtensionsOptions: { EnableAnalyzersSupport: true } }, - }, - yamlls: { - ...server('yaml-language-server', ['--stdio'], ['.yaml', '.yml'], ['.git']), - settings: { yaml: { validate: true, format: { enable: true }, hover: true, completion: true }, redhat: { telemetry: { enabled: false } } }, - }, - terraformls: server('terraform-ls', ['serve'], ['.tf', '.tfvars'], ['.terraform', 'terraform.tfstate', '*.tf']), - dockerls: server('docker-langserver', ['--stdio'], ['.dockerfile', 'Dockerfile'], ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', '.dockerignore']), - 'helm-ls': server('helm_ls', ['serve'], ['.yaml', '.yml', '.tpl'], ['Chart.yaml', 'Chart.yml']), - nixd: server('nixd', [], ['.nix'], ['flake.nix', 'default.nix', 'shell.nix']), - nil: server('nil', [], ['.nix'], ['flake.nix', 'default.nix', 'shell.nix']), - ols: server('ols', [], ['.odin'], ['ols.json', '.git']), - dartls: { - ...server('dart', ['language-server', '--protocol=lsp'], ['.dart'], ['pubspec.yaml', 'pubspec.lock']), - initOptions: { closingLabels: true, flutterOutline: true, outline: true }, - }, - marksman: { - ...server('marksman', ['server'], ['.md', '.markdown'], ['.marksman.toml', '.git']), - warmupTimeoutMs: 2_000, - }, - texlab: { - ...server('texlab', [], ['.tex', '.bib', '.sty', '.cls'], ['.latexmkrc', 'latexmkrc', '.texlabroot', 'texlabroot', 'Tectonic.toml']), - settings: { texlab: { build: { executable: 'latexmk', args: ['-pdf', '-interaction=nonstopmode', '-synctex=1', '%f'] }, chktex: { onOpenAndSave: true } } }, - }, - graphql: server('graphql-lsp', ['server', '-m', 'stream'], ['.graphql', '.gql'], ['.graphqlrc', '.graphqlrc.json', '.graphqlrc.yml', '.graphqlrc.yaml', 'graphql.config.js']), - prismals: server('prisma-language-server', ['--stdio'], ['.prisma'], ['schema.prisma', 'prisma/schema.prisma']), - vimls: { - ...server('vim-language-server', ['--stdio'], ['.vim', '.vimrc'], ['.git']), - initOptions: { isNeovim: true, diagnostic: { enable: true } }, - }, - 'emmet-language-server': server('emmet-language-server', ['--stdio'], ['.html', '.css', '.scss', '.less', '.jsx', '.tsx', '.vue', '.svelte'], ['.git']), - 'sourcekit-lsp': server('sourcekit-lsp', [], ['.swift'], ['Package.swift', '*.xcodeproj', '*.xcworkspace', 'project.yml', '.swiftpm']), - swiftlint: { - ...server('swiftlint', ['lint', '--quiet', '--reporter', 'json'], ['.swift'], ['.swiftlint.yml', '.swiftlint.yaml', 'Package.swift', '*.xcodeproj'], 'linter'), - adapter: 'swiftlint', - }, -} - -export function supportsLspFile(serverConfig: Pick, filePath: string): boolean { - if (serverConfig.fileTypes.length === 0) return true - const lower = filePath.toLowerCase() - const extension = extname(lower) - const name = lower.slice(Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\')) + 1) - return serverConfig.fileTypes.some((fileType) => { - const normalized = fileType.toLowerCase() - return normalized === extension || normalized === name || normalized.replace(/^\./, '') === extension.slice(1) - }) -} - -export async function findLspWorkspaceRoot(cwd: string, filePath: string | undefined, markers: string[]): Promise { - if (markers.length === 0) return resolve(cwd) - let directory = filePath ? dirname(resolve(filePath)) : resolve(cwd) - const boundary = resolve(cwd) - while (true) { - if (await directoryHasMarker(directory, markers)) return directory - const parent = dirname(directory) - if (parent === directory || (directory === boundary && !filePath)) return undefined - directory = parent - } -} - -export async function resolveLspExecutable(command: string, workspaceRoot: string, configuredCwd?: string): Promise { - const cwd = resolve(configuredCwd ?? workspaceRoot) - if (isAbsolute(command)) return await executableFile(command) - const localCandidates = [ - join(cwd, command), - join(workspaceRoot, 'node_modules', '.bin', command), - join(workspaceRoot, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', command), - ] - for (const candidate of localCandidates) { - const executable = await executableFile(candidate) - if (executable) return executable - } - for (const directory of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) { - const executable = await executableFile(join(directory, command)) - if (executable) return executable - } - return undefined -} - -async function executableFile(candidate: string): Promise { - const extensions = process.platform === 'win32' - ? [...windowsExecutableExtensions(), ''] - : [''] - for (const extension of extensions) { - const path = candidate.toLowerCase().endsWith(extension) ? candidate : `${candidate}${extension}` - try { - await access(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK) - return resolve(path) - } catch { - // Try the next executable shim. - } - } - return undefined -} - -// Only .exe/.com binaries are directly spawnable on Windows; .cmd/.bat shims -// need the cmd.exe wrapper from the client, and the extensionless -// node_modules/.bin shell scripts cannot run at all, so probe last. -function windowsExecutableExtensions(): string[] { - const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') - .split(';') - .map((value) => value.toLowerCase()) - .filter(Boolean) - const rank = (extension: string): number => - extension === '.exe' || extension === '.com' ? 0 - : extension === '.cmd' || extension === '.bat' ? 1 - : 2 - return pathext.sort((left, right) => rank(left) - rank(right)) -} - -async function directoryHasMarker(directory: string, markers: string[]): Promise { - const exact = markers.filter((marker) => !marker.includes('*')) - if (exact.some((marker) => existsSync(join(directory, marker)))) return true - const globs = markers.filter((marker) => marker.includes('*')) - if (globs.length === 0) return false - let entries: string[] - try { - entries = await readdir(directory) - } catch { - return false - } - return globs.some((marker) => { - const expression = new RegExp(`^${marker.split('*').map(escapeRegExp).join('.*')}$`, 'i') - return entries.some((entry) => expression.test(entry)) - }) -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} diff --git a/packages/sdk/src/lsp/writethrough.test.ts b/packages/sdk/src/lsp/writethrough.test.ts deleted file mode 100644 index 1cde12803..000000000 --- a/packages/sdk/src/lsp/writethrough.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { SDKMessage, ToolContext } from '../types.js' -import { shutdownLspClients } from './client.js' -import { prepareLspWritethrough, prepareLspWritethroughBatch } from './writethrough.js' - -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -describe('LSP write-through coordinator', () => { - test('keeps formatting opt-in and drops diagnostics for a superseded mutation', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-writethrough-')) - const source = join(root, 'index.ts') - const serverScript = join(root, 'server.mjs') - const events: SDKMessage[] = [] - try { - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, 'const value=1\n') - await writeFile(serverScript, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - while (true) { - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const length = Number(match[1]) - if (buffer.length < end + 4 + length) return - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length)) - buffer = buffer.subarray(end + 4 + length) - if (message.method === 'initialize') { - send({ jsonrpc: '2.0', id: message.id, result: { capabilities: { documentFormattingProvider: true } } }) - } else if (message.method === 'textDocument/formatting') { - send({ jsonrpc: '2.0', id: message.id, result: [{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 100 } }, - newText: 'const value = 1' - }] }) - } else if (message.method === 'textDocument/didSave') { - const uri = message.params.textDocument.uri - setTimeout(() => send({ - jsonrpc: '2.0', - method: 'textDocument/publishDiagnostics', - params: { - uri, - diagnostics: [{ - range: { start: { line: 0, character: 6 }, end: { line: 0, character: 11 } }, - severity: 1, - message: 'delayed diagnostic' - }] - } - }), 650) - } else if (message.method === 'shutdown') { - send({ jsonrpc: '2.0', id: message.id, result: null }) - } else if (message.method === 'exit') { - process.exit(0) - } - } -}) -`) - const server = { - command: process.execPath, - args: [serverScript], - fileTypes: ['.ts'], - rootMarkers: ['package.json'], - } - const baseContext = { - cwd: root, - sessionId: 'session', - toolUseId: 'write-1', - emitEvent: (event: SDKMessage) => events.push(event), - } as ToolContext - - const unformatted = await prepareLspWritethrough({ - filePath: source, - content: 'const value=1\n', - context: { - ...baseContext, - toolConfig: { - lsp: { - diagnosticsOnWrite: false, - formatOnWrite: false, - servers: { test: server }, - }, - }, - }, - existedBefore: true, - }) - expect(unformatted.content).toBe('const value=1\n') - - const formatted = await prepareLspWritethrough({ - filePath: source, - content: 'const value=1\n', - context: { - ...baseContext, - toolConfig: { - lsp: { - diagnosticsOnWrite: false, - formatOnWrite: true, - servers: { test: server }, - }, - }, - }, - existedBefore: true, - }) - expect(formatted.content).toBe('const value = 1\n') - - const delayed = await prepareLspWritethrough({ - filePath: source, - content: 'const value = 2\n', - context: { - ...baseContext, - toolConfig: { - lsp: { - diagnosticsOnWrite: true, - formatOnWrite: false, - servers: { test: server }, - }, - }, - }, - existedBefore: true, - }) - await writeFile(source, delayed.content) - const result = await delayed.commit() - expect(result.diagnosticsDelayed).toBe(true) - - await writeFile(source, 'const value = 3\n') - await wait(1_100) - expect(events).toEqual([]) - } finally { - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }, 20_000) - - test('emits a delayed diagnostic for the current file mutation', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-current-diagnostics-')) - const source = join(root, 'index.ts') - const serverScript = join(root, 'server.mjs') - const events: SDKMessage[] = [] - try { - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, 'const value = 1\n') - await writeFile(serverScript, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - while (true) { - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const length = Number(buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i)?.[1]) - if (!length || buffer.length < end + 4 + length) return - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length)) - buffer = buffer.subarray(end + 4 + length) - if (message.method === 'initialize') { - send({ jsonrpc: '2.0', id: message.id, result: { capabilities: {} } }) - } else if (message.method === 'textDocument/didSave') { - setTimeout(() => send({ - jsonrpc: '2.0', - method: 'textDocument/publishDiagnostics', - params: { - uri: message.params.textDocument.uri, - diagnostics: [{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - severity: 2, - message: 'current diagnostic' - }] - } - }), 650) - } else if (message.method === 'shutdown') { - send({ jsonrpc: '2.0', id: message.id, result: null }) - } else if (message.method === 'exit') { - process.exit(0) - } - } -}) -`) - const context = { - cwd: root, - sessionId: 'session', - toolUseId: 'write-2', - toolConfig: { - lsp: { - servers: { - test: { - command: process.execPath, - args: [serverScript], - fileTypes: ['.ts'], - rootMarkers: ['package.json'], - }, - }, - }, - }, - emitEvent: (event: SDKMessage) => events.push(event), - } as ToolContext - const prepared = await prepareLspWritethrough({ - filePath: source, - content: 'const value = 2\n', - context, - existedBefore: true, - }) - await writeFile(source, prepared.content) - expect((await prepared.commit()).diagnosticsDelayed).toBe(true) - await wait(1_100) - - expect(events).toContainEqual(expect.objectContaining({ - type: 'system', - subtype: 'lsp_diagnostics', - file_path: source, - delayed: true, - diagnostics: expect.objectContaining({ - total: 1, - warnings: 1, - items: [expect.objectContaining({ message: 'current diagnostic' })], - }), - })) - } finally { - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }, 20_000) - - test('batch commit reports real formatting and mutation version without diagnostics', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-writethrough-batch-')) - const source = join(root, 'index.ts') - const serverScript = join(root, 'server.mjs') - try { - await writeFile(join(root, 'package.json'), '{}') - await writeFile(source, 'const value=1\n') - await writeFile(serverScript, ` -let buffer = Buffer.alloc(0) -const send = (value) => { - const body = Buffer.from(JSON.stringify(value)) - process.stdout.write(Buffer.concat([Buffer.from('Content-Length: ' + body.length + '\\r\\n\\r\\n'), body])) -} -process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]) - while (true) { - const end = buffer.indexOf('\\r\\n\\r\\n') - if (end < 0) return - const match = buffer.subarray(0, end).toString().match(/Content-Length:\\s*(\\d+)/i) - if (!match) process.exit(2) - const length = Number(match[1]) - if (buffer.length < end + 4 + length) return - const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length)) - buffer = buffer.subarray(end + 4 + length) - if (message.method === 'initialize') { - send({ jsonrpc: '2.0', id: message.id, result: { capabilities: { documentFormattingProvider: true } } }) - } else if (message.method === 'textDocument/formatting') { - send({ jsonrpc: '2.0', id: message.id, result: [{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 100 } }, - newText: 'const value = 1' - }] }) - } else if (message.id !== undefined) { - send({ jsonrpc: '2.0', id: message.id, result: null }) - } - } -}) -`) - const prepared = await prepareLspWritethroughBatch({ - files: [{ filePath: source, content: 'const value=1\n', existedBefore: true }], - context: { - cwd: root, - sessionId: 'session-batch', - toolConfig: { - lsp: { - diagnosticsOnWrite: false, - formatOnWrite: true, - servers: { - test: { - command: process.execPath, - args: [serverScript], - fileTypes: ['.ts'], - rootMarkers: ['package.json'], - }, - }, - }, - }, - } as ToolContext, - }) - expect(prepared.contents.get(source)).toBe('const value = 1\n') - const result = await prepared.commit() - expect(result.formatted).toBe(true) - expect(result.mutationVersion).toBeGreaterThan(0) - expect(result.diagnosticsDelayed).toBe(false) - } finally { - await shutdownLspClients(root) - await rm(root, { recursive: true, force: true }) - } - }, 20_000) -}) diff --git a/packages/sdk/src/lsp/writethrough.ts b/packages/sdk/src/lsp/writethrough.ts deleted file mode 100644 index 9cbd0b210..000000000 --- a/packages/sdk/src/lsp/writethrough.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' -import { basename, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import type { - LspDiagnosticBatch, - LspWritethroughResult, - ToolContext, -} from '../types.js' -import { - applyTextEdits, - getLspClientsForFile, - type LspAggregatedDiagnostic, - type LspClient, - type LspTextEdit, -} from './client.js' -import { collectLspAdapterDiagnostics } from './adapters.js' - -interface PreparedLspWritethrough { - content: string - commit(): Promise -} - -export interface PreparedLspWritethroughBatch { - contents: Map - commit(): Promise -} - -const mutationVersions = new Map() -const diagnosticLedger = new Map>() -const INLINE_DIAGNOSTICS_TIMEOUT_MS = 500 -const DELAYED_DIAGNOSTICS_TIMEOUT_MS = 12_000 - -/** Drop per-session writethrough bookkeeping once the session is gone (#223). */ -export function clearLspWritethroughState(sessionId: string): void { - const prefix = `${sessionId}\0` - for (const key of [...mutationVersions.keys()]) { - if (key.startsWith(prefix)) mutationVersions.delete(key) - } - for (const key of [...diagnosticLedger.keys()]) { - if (key.startsWith(prefix)) diagnosticLedger.delete(key) - } -} - -export async function prepareLspWritethrough(input: { - filePath: string - content: string - context: ToolContext - existedBefore: boolean -}): Promise { - const filePath = resolve(input.filePath) - const lsp = lspOptions(input.context) - if (lsp.enabled === false) return noLsp(input.content) - - const clients = await withTimeout( - getLspClientsForFile(input.context.cwd, input.context.toolConfig, filePath), - lsp.warmupTimeoutMs, - ).catch(unavailableClients) - - const beforeSequence = new Map(clients.map((client) => [client.serverName, client.getDiagnosticsSequence()])) - let content = input.content - let formatted = false - const versions = new Map() - - for (const client of clients) { - const version = await client.syncContent(filePath, content).catch(() => 0) - versions.set(client.serverName, version) - } - - if (lsp.formatOnWrite) { - const formatter = clients.find((client) => { - const capabilities = client.getStatus().capabilities - return Boolean(capabilities.documentFormattingProvider || capabilities.formattingProvider) - }) - if (formatter) { - try { - const edits = await formatter.request('textDocument/formatting', { - textDocument: { uri: fileUri(filePath) }, - options: inferFormattingOptions(content), - }, lsp.requestTimeoutMs, input.context.abortSignal) - const formattedContent = applyTextEdits(content, edits ?? []) - if (formattedContent !== content) { - content = formattedContent - formatted = true - for (const client of clients) { - const version = await client.syncContent(filePath, content).catch(() => versions.get(client.serverName) ?? 0) - versions.set(client.serverName, version) - } - } - } catch { - // Formatting is advisory and must never invalidate a legal file write. - } - } - } - - const mutationKey = `${input.context.sessionId ?? input.context.runId ?? 'global'}\0${filePath}` - const mutationVersion = (mutationVersions.get(mutationKey) ?? 0) + 1 - mutationVersions.set(mutationKey, mutationVersion) - const expectedSha = sha256(content) - - return { - content, - async commit() { - await Promise.all(clients.map(async (client) => { - await client.notifyWatchedFiles([{ uri: fileUri(filePath), type: input.existedBefore ? 2 : 1 }]).catch(() => undefined) - await client.notifySaved(filePath).catch(() => undefined) - })) - if (!lsp.diagnosticsOnWrite) { - return { - servers: clients.map((client) => client.serverName), - formatted, - diagnosticsDelayed: false, - mutationVersion, - } - } - - const pending = Promise.all([ - collectFreshDiagnostics( - clients, - filePath, - versions, - beforeSequence, - DELAYED_DIAGNOSTICS_TIMEOUT_MS, - input.context.abortSignal, - ), - withTimeout( - collectLspAdapterDiagnostics(filePath, input.context), - DELAYED_DIAGNOSTICS_TIMEOUT_MS, - ).catch(() => undefined), - ]).then(([diagnostics, adapter]) => summarizeDiagnostics([ - ...diagnostics, - ...(adapter?.diagnostics.items.map((diagnostic) => ({ - ...diagnostic, - server: diagnostic.server ?? adapter.server, - })) ?? []), - ], input.context, filePath)) - const inline = await withTimeout(pending, INLINE_DIAGNOSTICS_TIMEOUT_MS).catch(() => undefined) - if (inline) { - return { - servers: inline.servers, - formatted, - diagnostics: inline, - diagnosticsDelayed: false, - mutationVersion, - } - } - - void pending.then(async (diagnostics) => { - if (mutationVersions.get(mutationKey) !== mutationVersion) return - const current = await readFile(filePath).catch(() => undefined) - if (!current || sha256(current) !== expectedSha) return - input.context.emitEvent?.({ - type: 'system', - subtype: 'lsp_diagnostics', - session_id: input.context.sessionId, - tool_use_id: input.context.toolUseId, - file_path: filePath, - mutation_version: mutationVersion, - sha256: expectedSha, - delayed: true, - diagnostics, - }) - }).catch(() => undefined) - - return { - servers: clients.map((client) => client.serverName), - formatted, - diagnosticsDelayed: true, - mutationVersion, - } - }, - } -} - -export async function prepareLspWritethroughBatch(input: { - files: Array<{ filePath: string; content: string; existedBefore: boolean }> - context: ToolContext -}): Promise { - const contents = new Map(input.files.map((file) => [resolve(file.filePath), file.content])) - const lsp = lspOptions(input.context) - if (lsp.enabled === false || input.files.length === 0) { - return { - contents, - async commit() { - return { servers: [], formatted: false, diagnosticsDelayed: false, mutationVersion: 0 } - }, - } - } - - const records: Array<{ - filePath: string - existedBefore: boolean - clients: LspClient[] - versions: Map - before: Map - mutationKey: string - mutationVersion: number - expectedSha: string - }> = [] - for (const file of input.files) { - const filePath = resolve(file.filePath) - const clients = await withTimeout( - getLspClientsForFile(input.context.cwd, input.context.toolConfig, filePath), - lsp.warmupTimeoutMs, - ).catch(unavailableClients) - const before = new Map(clients.map((client) => [client.serverName, client.getDiagnosticsSequence()])) - const versions = new Map() - for (const client of clients) { - versions.set(client.serverName, await client.syncContent(filePath, contents.get(filePath)!).catch(() => 0)) - } - if (lsp.formatOnWrite) { - const formatter = clients.find((client) => { - const capabilities = client.getStatus().capabilities - return Boolean(capabilities.documentFormattingProvider || capabilities.formattingProvider) - }) - if (formatter) { - try { - const current = contents.get(filePath)! - const edits = await formatter.request('textDocument/formatting', { - textDocument: { uri: fileUri(filePath) }, - options: inferFormattingOptions(current), - }, lsp.requestTimeoutMs, input.context.abortSignal) - const formatted = applyTextEdits(current, edits ?? []) - if (formatted !== current) { - contents.set(filePath, formatted) - for (const client of clients) { - versions.set(client.serverName, await client.syncContent(filePath, formatted).catch(() => versions.get(client.serverName) ?? 0)) - } - } - } catch { - // Formatting remains advisory for a batch. - } - } - } - const mutationKey = `${input.context.sessionId ?? input.context.runId ?? 'global'}\0${filePath}` - const mutationVersion = (mutationVersions.get(mutationKey) ?? 0) + 1 - mutationVersions.set(mutationKey, mutationVersion) - records.push({ - filePath, - existedBefore: file.existedBefore, - clients, - versions, - before, - mutationKey, - mutationVersion, - expectedSha: sha256(contents.get(filePath)!), - }) - } - - return { - contents, - async commit() { - const allClients = [...new Set(records.flatMap((record) => record.clients))] - await Promise.all(allClients.map(async (client) => { - const files = records.filter((record) => record.clients.includes(client)) - await client.notifyWatchedFiles(files.map((record) => ({ - uri: fileUri(record.filePath), - type: record.existedBefore ? 2 as const : 1 as const, - }))).catch(() => undefined) - await Promise.all(files.map((record) => client.notifySaved(record.filePath).catch(() => undefined))) - })) - if (!lsp.diagnosticsOnWrite) { - return { - servers: allClients.map((client) => client.serverName), - formatted: input.files.some((file) => contents.get(resolve(file.filePath)) !== file.content), - diagnosticsDelayed: false, - mutationVersion: Math.max(0, ...records.map((record) => record.mutationVersion)), - } - } - const pending = records.map((record) => Promise.all([ - collectFreshDiagnostics( - record.clients, - record.filePath, - record.versions, - record.before, - DELAYED_DIAGNOSTICS_TIMEOUT_MS, - input.context.abortSignal, - ), - withTimeout( - collectLspAdapterDiagnostics(record.filePath, input.context), - DELAYED_DIAGNOSTICS_TIMEOUT_MS, - ).catch(() => undefined), - ]).then(([diagnostics, adapter]) => summarizeDiagnostics([ - ...diagnostics, - ...(adapter?.diagnostics.items.map((diagnostic) => ({ - ...diagnostic, - server: diagnostic.server ?? adapter.server, - })) ?? []), - ], input.context, record.filePath))) - const inline = await withTimeout(Promise.all(pending), INLINE_DIAGNOSTICS_TIMEOUT_MS).catch(() => undefined) - if (!inline) { - for (const [index, diagnostics] of pending.entries()) { - const record = records[index]! - void diagnostics.then(async (batch) => { - if (mutationVersions.get(record.mutationKey) !== record.mutationVersion) return - const current = await readFile(record.filePath).catch(() => undefined) - if (!current || sha256(current) !== record.expectedSha) return - input.context.emitEvent?.({ - type: 'system', - subtype: 'lsp_diagnostics', - session_id: input.context.sessionId, - tool_use_id: input.context.toolUseId, - file_path: record.filePath, - mutation_version: record.mutationVersion, - sha256: record.expectedSha, - delayed: true, - diagnostics: batch, - }) - }).catch(() => undefined) - } - } - return { - servers: allClients.map((client) => client.serverName), - formatted: input.files.some((file) => contents.get(resolve(file.filePath)) !== file.content), - ...(inline ? { diagnostics: inline.reduce(mergeDiagnosticBatches) } : {}), - diagnosticsDelayed: !inline, - mutationVersion: Math.max(0, ...records.map((record) => record.mutationVersion)), - } - }, - } -} - -async function collectFreshDiagnostics( - clients: LspClient[], - filePath: string, - versions: Map, - beforeSequence: Map, - timeoutMs: number, - signal?: AbortSignal, -): Promise { - const results = await Promise.all(clients.map(async (client) => { - try { - const diagnostics = await client.waitForDiagnostics( - filePath, - timeoutMs, - signal, - versions.get(client.serverName), - beforeSequence.get(client.serverName) ?? 0, - ) - return diagnostics.map((diagnostic) => ({ ...diagnostic, server: client.serverName })) - } catch { - return [] - } - })) - const seen = new Set() - return results.flat().filter((diagnostic) => { - const key = diagnosticKey(diagnostic) - if (seen.has(key)) return false - seen.add(key) - return true - }) -} - -async function summarizeDiagnostics( - diagnostics: LspAggregatedDiagnostic[], - context: ToolContext, - filePath: string, -): Promise { - const ledgerKey = `${context.sessionId ?? context.runId ?? 'global'}\0${filePath}` - const rawLsp = context.toolConfig?.lsp - const deduplicate = !(rawLsp && typeof rawLsp === 'object' && !Array.isArray(rawLsp) - && (rawLsp as Record).diagnosticsDeduplicate === false) - const previous = deduplicate ? diagnosticLedger.get(ledgerKey) ?? new Set() : new Set() - const current = new Set(diagnostics.map(diagnosticKey)) - if (deduplicate) diagnosticLedger.set(ledgerKey, current) - const unseen = diagnostics.filter((diagnostic) => !previous.has(diagnosticKey(diagnostic))) - const complete = unseen - const items: LspDiagnosticBatch['items'] = [] - let characters = 0 - for (const diagnostic of complete) { - if (items.length >= 50 || characters + diagnostic.message.length > 8_000) break - characters += diagnostic.message.length - items.push(diagnostic) - } - const summary: LspDiagnosticBatch = { - servers: [...new Set(diagnostics.map((diagnostic) => diagnostic.server))], - total: complete.length, - errors: complete.filter((diagnostic) => diagnostic.severity === 1).length, - warnings: complete.filter((diagnostic) => diagnostic.severity === 2).length, - truncated: items.length < complete.length, - items, - } - if (summary.truncated && context.artifactsRoot) { - try { - const directory = join(context.artifactsRoot, 'lsp-diagnostics') - await mkdir(directory, { recursive: true }) - const path = join(directory, `${basename(filePath)}-${Date.now()}.json`) - const body = JSON.stringify(complete, null, 2) - await writeFile(path, body, 'utf8') - const info = await stat(path) - summary.artifact = { kind: 'file', path, size: info.size, mimeType: 'application/json' } - } catch { - // A missing artifact must not hide the bounded inline diagnostics. - } - } - return summary -} - -function unavailableClients(error: unknown): LspClient[] { - // The write proceeds without LSP, but a failing server start must not vanish. - console.error(`[LSP] ${error instanceof Error ? error.message : String(error)}`) - return [] -} - -function lspOptions(context: ToolContext): { - enabled: boolean - diagnosticsOnWrite: boolean - formatOnWrite: boolean - requestTimeoutMs?: number - warmupTimeoutMs: number -} { - const raw = context.toolConfig?.lsp - const lsp = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record : {} - return { - enabled: lsp.enabled !== false, - diagnosticsOnWrite: lsp.diagnosticsOnWrite !== false, - formatOnWrite: lsp.formatOnWrite === true, - requestTimeoutMs: typeof lsp.requestTimeoutMs === 'number' - ? positiveMs(lsp.requestTimeoutMs, 20_000, 300_000) - : undefined, - warmupTimeoutMs: positiveMs(lsp.warmupTimeoutMs, 5_000, 5_000), - } -} - -function noLsp(content: string): PreparedLspWritethrough { - return { - content, - async commit() { - return { servers: [], formatted: false, diagnosticsDelayed: false, mutationVersion: 0 } - }, - } -} - -function inferFormattingOptions(content: string): { tabSize: number; insertSpaces: boolean } { - const indentation = content.split(/\r?\n/).map((line) => line.match(/^( +|\t+)/)?.[1]).find(Boolean) - return { - tabSize: indentation && !indentation.includes('\t') ? Math.max(indentation.length, 1) : 2, - insertSpaces: !indentation?.includes('\t'), - } -} - -function fileUri(filePath: string): string { - return pathToFileURL(resolve(filePath)).toString() -} - -function diagnosticKey(diagnostic: LspAggregatedDiagnostic): string { - return [ - diagnostic.range.start.line, - diagnostic.range.start.character, - diagnostic.range.end.line, - diagnostic.range.end.character, - diagnostic.severity ?? '', - diagnostic.code ?? '', - diagnostic.message, - ].join('|') -} - -function mergeDiagnosticBatches(left: LspDiagnosticBatch, right: LspDiagnosticBatch): LspDiagnosticBatch { - const items = [...left.items, ...right.items] - return { - servers: [...new Set([...left.servers, ...right.servers])], - total: left.total + right.total, - errors: left.errors + right.errors, - warnings: left.warnings + right.warnings, - truncated: left.truncated || right.truncated, - items: items.slice(0, 50), - ...(left.artifact ? { artifact: left.artifact } : right.artifact ? { artifact: right.artifact } : {}), - } -} - -function sha256(value: string | Uint8Array): string { - return createHash('sha256').update(value).digest('hex') -} - -function positiveMs(value: unknown, fallback: number, maximum: number): number { - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? Math.min(Math.floor(value), maximum) - : fallback -} - -async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('LSP operation timed out')), timeoutMs) - }), - ]) - } finally { - if (timer) clearTimeout(timer) - } -} diff --git a/packages/sdk/src/plugins/codex-adapter.ts b/packages/sdk/src/plugins/codex-adapter.ts index 01191f56b..e6824b5aa 100644 --- a/packages/sdk/src/plugins/codex-adapter.ts +++ b/packages/sdk/src/plugins/codex-adapter.ts @@ -68,9 +68,8 @@ export function adaptCodexPlugin( // Fail-closed permission mapping (#346): capabilities are granted only when // the manifest explicitly declares the driving field — MCP registration only // with mcpServers, hook events only with hooks, and shell stays off (the - // format has no shell field; shell.allow gates LSP code execution). Missing - // fields fall back to the same defaults lume manifests get from - // inferDefaults (all denied). + // format has no shell field). Missing fields fall back to the same defaults + // lume manifests get from inferDefaults (all denied). const declaresMcpServers = typeof codex.mcpServers === "string"; const declaresHooks = typeof codex.hooks === "string"; diff --git a/packages/sdk/src/plugins/manifest.test.ts b/packages/sdk/src/plugins/manifest.test.ts index 0dc7dc087..f72934b91 100644 --- a/packages/sdk/src/plugins/manifest.test.ts +++ b/packages/sdk/src/plugins/manifest.test.ts @@ -86,21 +86,6 @@ describe("LumePluginManifest", () => { expect(() => parseManifest(raw)).toThrow("hooks"); }); - test("validates plugin LSP config as a package-relative path", () => { - expect(parseManifest({ - schema: "lume-plugin/v1", - name: "my-plugin", - version: "1.0.0", - lspServers: "./lsp.yaml", - }).lspServers).toBe("./lsp.yaml"); - expect(() => parseManifest({ - schema: "lume-plugin/v1", - name: "my-plugin", - version: "1.0.0", - lspServers: "../lsp.yaml", - })).toThrow("lspServers"); - }); - test("validates version is semver-like", () => { const raw = { schema: "lume-plugin/v1", diff --git a/packages/sdk/src/plugins/manifest.ts b/packages/sdk/src/plugins/manifest.ts index 653c094e2..d5c1a5f47 100644 --- a/packages/sdk/src/plugins/manifest.ts +++ b/packages/sdk/src/plugins/manifest.ts @@ -140,7 +140,6 @@ export interface LumePluginManifest { skills?: string[]; hooks?: string; mcpServers?: string; - lspServers?: string; commandTools?: Array>; permissions?: PluginPermissions; marketplace?: PluginMarketplaceManifest; @@ -194,9 +193,6 @@ export function parseManifest(raw: Record): LumePluginManifest if (typeof raw.mcpServers === "string") { validatePluginPath(raw.mcpServers, "mcpServers"); } - if (typeof raw.lspServers === "string") { - validatePluginPath(raw.lspServers, "lspServers"); - } const marketplace = normalizeMarketplace(raw.marketplace); @@ -215,7 +211,6 @@ export function parseManifest(raw: Record): LumePluginManifest : undefined, hooks: raw.hooks as string | undefined, mcpServers: raw.mcpServers as string | undefined, - lspServers: raw.lspServers as string | undefined, commandTools: Array.isArray(raw.commandTools) ? raw.commandTools.filter( (tool): tool is Record => diff --git a/packages/sdk/src/plugins/normalized.ts b/packages/sdk/src/plugins/normalized.ts index b53afa4eb..321c649d8 100644 --- a/packages/sdk/src/plugins/normalized.ts +++ b/packages/sdk/src/plugins/normalized.ts @@ -23,7 +23,6 @@ export interface PluginDiagnostic { | "duplicate_plugin_ignored" | "permission_review_required" | "capability_filtered" - | "lsp_config_invalid" | "mcp_start_failed" | "orphaned_install" | "command_tool_invalid"; @@ -53,7 +52,6 @@ export interface PluginManifestCapabilities { skills: PluginSkillContribution[]; hooksConfigPath?: string; mcpServersConfigPath?: string; - lspServersConfigPath?: string; commandTools: CommandToolContribution[]; } @@ -155,7 +153,6 @@ function normalizeLumeManifest( })), ...(manifest.hooks ? { hooksConfigPath: manifest.hooks } : {}), ...(manifest.mcpServers ? { mcpServersConfigPath: manifest.mcpServers } : {}), - ...(manifest.lspServers ? { lspServersConfigPath: manifest.lspServers } : {}), commandTools, }, permissions: manifest.permissions ?? {}, diff --git a/packages/sdk/src/plugins/permissions-hash.test.ts b/packages/sdk/src/plugins/permissions-hash.test.ts index 8c340837e..5ef1e56e5 100644 --- a/packages/sdk/src/plugins/permissions-hash.test.ts +++ b/packages/sdk/src/plugins/permissions-hash.test.ts @@ -52,35 +52,6 @@ describe("computePermissionsHash", () => { expect(before).not.toBe(after); }); - test("changes when the reviewed LSP config path changes", () => { - const before = computePermissionsHash(basePlugin({ - capabilities: { skills: [], commandTools: [], lspServersConfigPath: "./lsp.json" }, - })); - const after = computePermissionsHash(basePlugin({ - capabilities: { skills: [], commandTools: [], lspServersConfigPath: "./lsp-v2.json" }, - })); - expect(before).not.toBe(after); - }); - - test("changes when the reviewed LSP config content changes", () => { - const root = mkdtempSync(join(tmpdir(), "lume-plugin-lsp-hash-")); - try { - mkdirSync(join(root, "config")); - const path = join(root, "config", "lsp.json"); - const plugin = basePlugin({ - root, - capabilities: { skills: [], commandTools: [], lspServersConfigPath: "./config/lsp.json" }, - }); - writeFileSync(path, '{"servers":{"ts":{"command":"ts-a"}}}'); - const before = computePermissionsHash(plugin); - writeFileSync(path, '{"servers":{"ts":{"command":"ts-b"}}}'); - const after = computePermissionsHash(plugin); - expect(before).not.toBe(after); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - test("changes when the hooks config content changes (#347)", () => { const root = mkdtempSync(join(tmpdir(), "lume-plugin-hooks-hash-")); try { diff --git a/packages/sdk/src/plugins/permissions-hash.ts b/packages/sdk/src/plugins/permissions-hash.ts index 513bd5dbf..41ee5e1ce 100644 --- a/packages/sdk/src/plugins/permissions-hash.ts +++ b/packages/sdk/src/plugins/permissions-hash.ts @@ -17,7 +17,7 @@ import type { NormalizedPlugin } from "./normalized.js"; * enablement state. A pure version bump that keeps permissions/capabilities * unchanged may reuse a previous approval. * - * Hooks/MCP/LSP config files contribute both their path and their content + * Hooks/MCP config files contribute both their path and their content * hash (#347): hooks and MCP servers are command-execution entry points, so a * post-approval edit of hooks.json / mcp.json must force a re-review just like * a commandTool change does. Command tools are fully resolved in @@ -38,8 +38,6 @@ interface PermissionSummary { hooksConfigHash: string | null; mcpServersConfigPath: string | null; mcpServersConfigHash: string | null; - lspServersConfigPath: string | null; - lspServersConfigHash: string | null; commandTools: Array>; }; } @@ -75,8 +73,6 @@ function canonicalSummary(plugin: NormalizedPlugin): PermissionSummary { hooksConfigHash: capabilityFileHash(plugin, plugin.capabilities.hooksConfigPath), mcpServersConfigPath: plugin.capabilities.mcpServersConfigPath ?? null, mcpServersConfigHash: capabilityFileHash(plugin, plugin.capabilities.mcpServersConfigPath), - lspServersConfigPath: plugin.capabilities.lspServersConfigPath ?? null, - lspServersConfigHash: capabilityFileHash(plugin, plugin.capabilities.lspServersConfigPath), commandTools, }, }; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 818cca95b..00607aba7 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -7,7 +7,6 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'fs/promises' import { join, resolve } from 'path' -import { clearLspWritethroughState } from './lsp/writethrough.js' import type { NormalizedMessageParam } from './providers/types.js' import type { FileCheckpointState, @@ -447,7 +446,6 @@ export async function appendToSession( } export async function deleteSession(sessionId: string): Promise { - clearLspWritethroughState(sessionId) let deleted = false try { for (const root of getSessionDirCandidates()) { diff --git a/packages/sdk/src/tools/edit.ts b/packages/sdk/src/tools/edit.ts index fe2bd6b11..b3914e8ff 100644 --- a/packages/sdk/src/tools/edit.ts +++ b/packages/sdk/src/tools/edit.ts @@ -6,7 +6,6 @@ import { readFile, stat } from 'fs/promises' import { defineTool } from './types.js' import type { ToolContext } from '../types.js' import { ensurePathAllowed, getUnsafeFilePathReason, resolveInputPath } from '../utils/pathing.js' -import { prepareLspWritethrough } from '../lsp/writethrough.js' import { decodeTextFile, encodeTextFile } from '../utils/text-file.js' import { countLineChanges } from '../utils/line-change-stats.js' import { withFileMutationLock } from '../utils/file-mutation-lock.js' @@ -107,11 +106,8 @@ export const FileEditTool = defineTool({ } const match = matches[0]! content = replaceRanges(content, [match], new_string, old_string.length) - const lsp = await prepareLspWritethrough({ filePath, content, context, existedBefore: true }) - content = lsp.content const lineChanges = countLineChanges(decoded.content, content) await writeFileAtomic(filePath, encodeTextFile(content, decoded), assertWriteAllowed(context)) - const lspResult = await lsp.commit() await updateFileState(context, filePath, content) return { data: { @@ -130,17 +126,13 @@ export const FileEditTool = defineTool({ ...(match.normalized ? { normalizedQuotes: true } : {}), ...lineChanges, }, - lsp: lspResult, }, } } else { const count = matches.length content = replaceRanges(content, matches, new_string, old_string.length) - const lsp = await prepareLspWritethrough({ filePath, content, context, existedBefore: true }) - content = lsp.content const lineChanges = countLineChanges(decoded.content, content) await writeFileAtomic(filePath, encodeTextFile(content, decoded), assertWriteAllowed(context)) - const lspResult = await lsp.commit() await updateFileState(context, filePath, content) return { data: { @@ -159,7 +151,6 @@ export const FileEditTool = defineTool({ ...(matches.some((match) => match.normalized) ? { normalizedQuotes: true } : {}), ...lineChanges, }, - lsp: lspResult, }, } } diff --git a/packages/sdk/src/tools/index.ts b/packages/sdk/src/tools/index.ts index ce20b66ef..e480ef027 100644 --- a/packages/sdk/src/tools/index.ts +++ b/packages/sdk/src/tools/index.ts @@ -43,9 +43,6 @@ import { AskUserQuestionTool } from './ask-user.js' // Discovery import { ToolSearchTool } from './tool-search.js' -// LSP -import { LSPApplyTool, LSPTool } from './lsp-tool.js' - // Todo import { createTodoTool } from './todo-tool.js' @@ -88,10 +85,6 @@ const ALL_TOOLS: ToolDefinition[] = [ // Discovery ToolSearchTool, - // LSP - LSPTool, - LSPApplyTool, - // Skill SkillTool, ] @@ -99,7 +92,7 @@ const ALL_TOOLS: ToolDefinition[] = [ /** Schemas always sent to the provider when deferred tool loading is enabled. */ export const CORE_TOOL_NAMES = new Set([ 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'NotebookEdit', - 'WebFetch', 'WebSearch', 'Agent', 'AskUserQuestion', 'Skill', 'LSP', 'LSPApply', + 'WebFetch', 'WebSearch', 'Agent', 'AskUserQuestion', 'Skill', 'ProcessOutput', 'ProcessStop', 'TaskOutput', 'TaskStop', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate', ]) @@ -175,9 +168,6 @@ export { AskUserQuestionTool, // Discovery ToolSearchTool, - // LSP - LSPTool, - LSPApplyTool, // Todo createTodoTool, // Skill @@ -195,7 +185,5 @@ export function splitDeferredTools(tools: ToolDefinition[]): { return registry.agent("adapter").view().split(); } -export type { LspWorkspaceEditPreview } from './lsp-tool.js' - // Re-export helpers export { defineTool, toApiTool } from './types.js' diff --git a/packages/sdk/src/tools/lsp-tool.test.ts b/packages/sdk/src/tools/lsp-tool.test.ts deleted file mode 100644 index 556feedd9..000000000 --- a/packages/sdk/src/tools/lsp-tool.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { - LSPApplyTool, - LSPTool, - applyWorkspaceEdit, - mergeRenameWorkspaceEdits, - resolveAgentPosition, - workspaceDiagnosticsCommand, -} from './lsp-tool.js' - -describe('LSP tool boundaries', () => { - test('keeps queries read-only and separates mutations', () => { - expect(LSPTool.name).toBe('LSP') - expect(LSPTool.isReadOnly?.()).toBe(true) - expect(LSPTool.isConcurrencySafe?.()).toBe(true) - expect(LSPApplyTool.name).toBe('LSPApply') - expect(LSPApplyTool.isReadOnly?.()).toBe(false) - expect(LSPApplyTool.isConcurrencySafe?.()).toBe(false) - }) - - test('resolves 1-based lines and symbol occurrence selectors', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-position-')) - const file = join(root, 'index.ts') - try { - await writeFile(file, 'const value = 1\nconst value2 = value\n') - expect(await resolveAgentPosition(file, { line_number: 2 })).toEqual({ line: 1, character: 0 }) - expect(await resolveAgentPosition(file, { symbol: 'value#2' })).toEqual({ line: 1, character: 6 }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('prefers primary rename edits over linter conflicts', () => { - const merged = mergeRenameWorkspaceEdits([ - { - server: 'linter', - result: { changes: { 'file:///index.ts': [{ - range: { start: { line: 0, character: 2 }, end: { line: 0, character: 6 } }, - newText: 'linter', - }] } }, - }, - { - server: 'primary', - result: { changes: { 'file:///index.ts': [{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - newText: 'primary', - }] } }, - }, - ], new Map([['primary', 'primary'], ['linter', 'linter']])) - - expect(merged.changes?.['file:///index.ts']).toEqual([{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - newText: 'primary', - }]) - }) - - test('rejects overlapping edits returned by equally authoritative rename servers', () => { - expect(() => mergeRenameWorkspaceEdits([ - { - server: 'primary-a', - result: { changes: { 'file:///index.ts': [{ - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - newText: 'first', - }] } }, - }, - { - server: 'primary-b', - result: { changes: { 'file:///index.ts': [{ - range: { start: { line: 0, character: 2 }, end: { line: 0, character: 6 } }, - newText: 'second', - }] } }, - }, - ])).toThrow('Conflicting') - }) - - test('defaults renameFile application to LSPApply while keeping explicit preview read-only', async () => { - const context = { cwd: process.cwd() } as any - const applyResult = await LSPApplyTool.call({ - operation: 'renameFile', - file_path: 'missing.ts', - new_path: 'renamed.ts', - }, context) - const wrongToolResult = await LSPTool.call({ - operation: 'renameFile', - file_path: 'missing.ts', - new_path: 'renamed.ts', - }, context) - const previewResult = await LSPTool.call({ - operation: 'renameFile', - file_path: 'missing.ts', - new_path: 'renamed.ts', - apply: false, - }, context) - - expect(String(applyResult.content)).not.toContain('LSPApply only accepts') - expect(String(wrongToolResult.content)).toContain('use LSPApply') - expect(String(previewResult.content)).not.toContain('use LSPApply') - }) - - test('keeps edited children inside a renamed directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-directory-rename-')) - const oldDirectory = join(root, 'old') - const newDirectory = join(root, 'new') - const oldFile = join(oldDirectory, 'index.ts') - const newFile = join(newDirectory, 'index.ts') - try { - await mkdir(oldDirectory) - await writeFile(oldFile, 'export const value = 1\n') - - await applyWorkspaceEdit({ - changes: { - [pathToFileURL(oldFile).toString()]: [{ - range: { - start: { line: 0, character: 21 }, - end: { line: 0, character: 22 }, - }, - newText: '2', - }], - }, - documentChanges: [{ - kind: 'rename', - oldUri: pathToFileURL(oldDirectory).toString(), - newUri: pathToFileURL(newDirectory).toString(), - }], - }, { cwd: root } as any) - - expect(await readFile(newFile, 'utf8')).toBe('export const value = 2\n') - expect(await stat(oldDirectory).catch(() => undefined)).toBeUndefined() - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('builds every module declared by a go.work fallback', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-go-work-')) - try { - await writeFile(join(root, 'go.work'), [ - 'go 1.22', - 'use (', - ' ./service-a', - ' "./service b"', - ')', - ].join('\n')) - - expect(await workspaceDiagnosticsCommand(root)).toBe( - // paths with spaces are single-quoted ('' escaping), plain paths stay bare (#198) - "go build ./service-a/... './service b/...'", - ) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('refuses resource operations outside workspace roots and UNC targets (#197)', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-resource-guard-')) - const outside = join(root, '..', `lume-lsp-resource-out-${Date.now()}`) - try { - await mkdir(outside) - await writeFile(join(outside, 'a.ts'), 'x') - - const ctx = { cwd: root } as never - - // delete of a directory outside every root: refused - await expect(applyWorkspaceEdit({ - documentChanges: [{ kind: 'delete', uri: pathToFileURL(outside).toString(), options: { recursive: true } }], - }, ctx)).rejects.toThrow(/workspace roots/) - - // delete of the workspace root itself: refused (strict) - await expect(applyWorkspaceEdit({ - documentChanges: [{ kind: 'delete', uri: pathToFileURL(root).toString(), options: { recursive: true } }], - }, ctx)).rejects.toThrow(/workspace root/) - - // UNC paths reach the unsafe-path screening before any filesystem call - await expect(applyWorkspaceEdit({ - documentChanges: [{ kind: 'create', uri: 'file://server/share/evil.ts' }], - }, ctx)).rejects.toThrow() - - // the outside victim survived everything - expect(await stat(join(outside, 'a.ts'))).toBeTruthy() - } finally { - await rm(root, { recursive: true, force: true }) - await rm(outside, { recursive: true, force: true }) - } - }) - - test('drops go.work use entries that carry shell metacharacters (#198)', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-go-work-inject-')) - try { - await writeFile(join(root, 'go.work'), [ - 'go 1.22', - 'use (', - ' ./api', - ' "./x$(curl evil)"', - ' "./y`id`"', - ')', - ].join('\n')) - - const command = await workspaceDiagnosticsCommand(root) - expect(command).toBe('go build ./api/...') - expect(command).not.toContain('$(') - expect(command).not.toContain('`') - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - test('routes workspace diagnostics fallback through the nested Bash tool', async () => { - const root = await mkdtemp(join(tmpdir(), 'lume-lsp-workspace-diagnostics-')) - const calls: Array<{ toolName: string; params: unknown }> = [] - try { - await writeFile(join(root, 'tsconfig.json'), '{}') - const result = await LSPTool.call({ - operation: 'diagnostics', - file_path: '*', - }, { - cwd: root, - toolConfig: { lsp: { enabled: false } }, - async executeNestedTool(call: { toolName: string; params: unknown }) { - calls.push(call) - return { - type: 'tool_result', - tool_use_id: 'nested', - content: 'typecheck completed', - _meta: { execution: { version: 2, outcome: 'succeeded' } }, - } as any - }, - } as any) - - expect(calls).toEqual([{ - toolName: 'Bash', - params: { - command: 'npx tsc --noEmit', - purpose: 'verification', - description: 'LSP workspace diagnostics fallback', - }, - }]) - expect(String(result.content)).toContain('typecheck completed') - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/sdk/src/tools/lsp-tool.ts b/packages/sdk/src/tools/lsp-tool.ts deleted file mode 100644 index 4782f6733..000000000 --- a/packages/sdk/src/tools/lsp-tool.ts +++ /dev/null @@ -1,1124 +0,0 @@ -/** - * LSPTool - real Language Server Protocol code intelligence. - * - * The SDK owns the JSON-RPC client, while the language server remains an - * external process. Configure `toolConfig.lsp.command`/`args` when the - * workspace does not expose `typescript-language-server --stdio`. - */ - -import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, isAbsolute, relative, resolve, join, sep } from 'node:path' -import { pathToFileURL } from 'node:url' -import { defineTool } from './types.js' -import type { ToolContext } from '../types.js' -import { ensurePathAllowed, getUnsafeFilePathReason } from '../utils/pathing.js' -import { - applyTextEdits, - collectLspDiagnostics, - filePathFromUri, - getLspClientsForFile, - notifyLspFileClosed, - notifyLspFileChanged, - requestLspClients, - type LspClient, - type LspLocation, - type LspLocationLink, - type LspTextEdit, - type LspWorkspaceEdit, -} from '../lsp/client.js' -import { prepareLspWritethroughBatch } from '../lsp/writethrough.js' - -const locationOperations = new Set([ - 'goToDefinition', - 'findReferences', - 'goToImplementation', -]) - -const LSP_MUTATION_OPERATIONS = new Set(['rename', 'renameFile', 'codeActions', 'formatting', 'rangeFormatting', 'applyWorkspaceEdit']) -const LSP_READONLY_REQUESTS = new Set([ - 'textDocument/definition', - 'textDocument/references', - 'textDocument/implementation', - 'textDocument/typeDefinition', - 'textDocument/hover', - 'textDocument/documentSymbol', - 'textDocument/prepareRename', - 'textDocument/prepareCallHierarchy', - 'callHierarchy/incomingCalls', - 'callHierarchy/outgoingCalls', - 'textDocument/diagnostic', - 'workspace/symbol', -]) - -function isMutationRequest(operation: string, input: Record): boolean { - if (operation === 'request') return !LSP_READONLY_REQUESTS.has(typeof input.query === 'string' ? input.query.trim() : '') - if (!LSP_MUTATION_OPERATIONS.has(operation)) return false - return operation === 'rename' || operation === 'renameFile' || operation === 'applyWorkspaceEdit' - ? input.apply !== false - : input.apply === true -} - -function createLspTool(allowWrite: boolean) { - return defineTool({ - name: allowWrite ? 'LSPApply' : 'LSP', - description: allowWrite - ? 'Apply Language Server Protocol edits: rename, formatting, code actions, and WorkspaceEdit.' - : 'Read-only Language Server Protocol code intelligence: definitions, references, hover, symbols, diagnostics, call hierarchy, and previews.', - inputSchema: { - type: 'object', - properties: { - operation: { - type: 'string', - enum: [ - 'goToDefinition', - 'definition', - 'findReferences', - 'references', - 'hover', - 'documentSymbol', - 'symbols', - 'workspaceSymbol', - 'goToImplementation', - 'implementation', - 'typeDefinition', - 'prepareRename', - 'prepareCallHierarchy', - 'incomingCalls', - 'outgoingCalls', - 'diagnostics', - 'rename', - 'renameFile', - 'codeActions', - 'formatting', - 'rangeFormatting', - 'applyWorkspaceEdit', - 'status', - 'capabilities', - 'reload', - 'request', - ], - }, - file_path: { type: 'string', description: 'Workspace-relative or absolute file path' }, - line: { type: 'number', description: '0-based line' }, - character: { type: 'number', description: '0-based UTF-16 character' }, - line_number: { type: 'number', description: '1-based line; cannot be combined with line/character or symbol' }, - symbol: { type: 'string', description: 'Symbol substring, optionally suffixed with #N to select the Nth occurrence' }, - query: { type: 'string', description: 'Workspace symbol query' }, - new_name: { type: 'string', description: 'New symbol name for rename' }, - new_path: { type: 'string', description: 'Destination path for renameFile' }, - timeout_ms: { type: 'number', description: 'Request timeout in milliseconds (max 300000)' }, - item: { type: 'object', description: 'Call hierarchy item returned by prepareCallHierarchy' }, - diagnostics: { type: 'array', description: 'Diagnostics passed to textDocument/codeAction' }, - only: { type: 'array', description: 'Code action kinds to request' }, - apply: { type: 'boolean', description: 'Apply returned edits; false returns a preview' }, - action_index: { type: 'number', description: 'Code action index to apply' }, - end_line: { type: 'number', description: 'Optional range end line' }, - end_character: { type: 'number', description: 'Optional range end character' }, - tab_size: { type: 'number', description: 'Formatting tab size' }, - insert_spaces: { type: 'boolean', description: 'Formatting should use spaces' }, - payload: { type: ['object', 'string'], description: 'Raw JSON-RPC request payload' }, - server: { type: 'string', description: 'Optional LSP server name for a write operation or raw request' }, - }, - required: ['operation'], - }, - isReadOnly: !allowWrite, - isConcurrencySafe: !allowWrite, - async prompt() { - return 'Code intelligence backed by an external Language Server Protocol server.' - }, - async call(input, context) { - try { - const operation = String(input.operation ?? '') - if (isMutationRequest(operation, input) !== allowWrite) { - return { - data: allowWrite - ? 'LSPApply only accepts operations that apply a mutation' - : 'This LSP operation is read-only; use LSPApply to modify files', - is_error: true, - } - } - const diagnosticsPattern = operation === 'diagnostics' && typeof input.file_path === 'string' && input.file_path.includes('*') - ? input.file_path - : undefined - const workspaceDiagnostics = diagnosticsPattern === '*' - const filePath = input.file_path && !diagnosticsPattern ? resolve(context.cwd, String(input.file_path)) : undefined - if (filePath) { - const sandboxError = ensurePathAllowed( - filePath, - 'read', - context.sandbox, - context.additionalDirectories, - ) - if (sandboxError) return { data: sandboxError, is_error: true } - } - if (locationOperations.has(operation) || ['definition', 'references', 'implementation', 'typeDefinition', 'hover', 'documentSymbol', 'symbols', 'rename', 'renameFile', 'prepareRename', 'diagnostics', 'prepareCallHierarchy', 'codeActions', 'formatting', 'rangeFormatting'].includes(operation)) { - if (diagnosticsPattern) { - // Workspace and glob diagnostics intentionally have no single file. - } else - if (!filePath) return { data: 'file_path is required for this operation', is_error: true } - if (!['diagnostics', 'documentSymbol', 'symbols', 'formatting', 'renameFile'].includes(operation)) { - const positionError = validatePositionInput(input) - if (positionError) return { data: positionError, is_error: true } - } - } - if (operation === 'renameFile' && (typeof input.new_path !== 'string' || !input.new_path.trim())) { - return { data: 'new_path is required for renameFile', is_error: true } - } - if ((operation === 'incomingCalls' || operation === 'outgoingCalls') && !input.item) { - return { data: 'item is required for call hierarchy traversal', is_error: true } - } - const fileIsDirectory = operation === 'renameFile' && filePath - ? (await stat(filePath).catch(() => undefined))?.isDirectory() === true - : false - if (diagnosticsPattern && !workspaceDiagnostics) { - const matches = await expandDiagnosticGlob(context.cwd, diagnosticsPattern, 20) - if (matches.length === 0) return { data: 'No files matched the diagnostics glob', is_error: true } - const output: string[] = [] - for (const match of matches) { - const matchedClients = await getLspClientsForFile(context.cwd, context.toolConfig, match).catch(() => []) - if (matchedClients.length === 0) continue - await Promise.all(matchedClients.map((candidate) => candidate.syncFile(match))) - output.push(formatDiagnostics( - await collectLspDiagnostics(matchedClients, match, 3_000, context.abortSignal), - match, - context.cwd, - )) - } - return { data: output.join('\n') || 'No matching LSP diagnostics available' } - } - - const clients = await getLspClientsForFile(context.cwd, context.toolConfig, fileIsDirectory ? undefined : filePath).catch((error) => { - if (workspaceDiagnostics) return [] - throw error - }) - if (workspaceDiagnostics && clients.length === 0) { - const fallback = await executeWorkspaceDiagnosticsFallback(context) - const fallbackMeta = '_meta' in fallback ? fallback._meta : undefined - return { - data: toolResultText(fallback), - ...(fallback.is_error ? { is_error: true } : {}), - ...(fallbackMeta ? { _meta: fallbackMeta } : {}), - } - } - const client = selectClient(clients, input.server) - if (!client) return { data: 'No matching LSP server is available', is_error: true } - if (filePath && !fileIsDirectory) await Promise.all(clients.map((candidate) => candidate.syncFile(filePath))) - const requestTimeoutMs = typeof input.timeout_ms === 'number' - ? Math.min(Math.max(Math.floor(input.timeout_ms), 1), 300_000) - : undefined - const requestAll = (method: string, params: unknown, timeoutMs = requestTimeoutMs) => - requestLspClients(clients, method, params, timeoutMs, context.abortSignal) - const position = filePath && operationNeedsPosition(operation) - ? await resolveAgentPosition(filePath, input) - : undefined - const endPosition = filePath && typeof input.end_line === 'number' && typeof input.end_character === 'number' - ? { line: Number(input.end_line), character: Number(input.end_character) } - : position - let result: unknown - - switch (operation) { - case 'goToDefinition': - case 'definition': - result = aggregateLocations(await requestAll('textDocument/definition', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'findReferences': - case 'references': - result = aggregateLocations(await requestAll('textDocument/references', { - textDocument: { uri: uriFor(filePath!) }, position, context: { includeDeclaration: true }, - })) - break - case 'goToImplementation': - case 'implementation': - result = aggregateLocations(await requestAll('textDocument/implementation', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'typeDefinition': - result = aggregateLocations(await requestAll('textDocument/typeDefinition', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'hover': - result = firstNonEmptyServerResult(await requestAll('textDocument/hover', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'documentSymbol': - case 'symbols': - result = aggregateServerArrays(await requestAll('textDocument/documentSymbol', { textDocument: { uri: uriFor(filePath!) } })) - break - case 'workspaceSymbol': - if (!input.query) return { data: 'query is required for workspaceSymbol', is_error: true } - result = aggregateServerArrays(await requestAll('workspace/symbol', { query: String(input.query) })) - break - case 'prepareCallHierarchy': - result = aggregateServerArrays(await requestAll('textDocument/prepareCallHierarchy', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'prepareRename': - result = firstNonEmptyServerResult(await requestAll('textDocument/prepareRename', { textDocument: { uri: uriFor(filePath!) }, position })) - break - case 'incomingCalls': - if (!input.item) return { data: 'item is required for incomingCalls', is_error: true } - result = aggregateServerArrays(await requestAll('callHierarchy/incomingCalls', { item: input.item })) - break - case 'outgoingCalls': - if (!input.item) return { data: 'item is required for outgoingCalls', is_error: true } - result = aggregateServerArrays(await requestAll('callHierarchy/outgoingCalls', { item: input.item })) - break - case 'diagnostics': - if (workspaceDiagnostics) { - const workspaceResults = await requestAll('workspace/diagnostic', { previousResultIds: [] }) - if (workspaceResults.some(({ result }) => result !== null && result !== undefined)) { - result = workspaceResults - break - } - const fallback = await executeWorkspaceDiagnosticsFallback(context) - const fallbackMeta = '_meta' in fallback ? fallback._meta : undefined - return { - data: toolResultText(fallback), - ...(fallback.is_error ? { is_error: true } : {}), - ...(fallbackMeta ? { _meta: fallbackMeta } : {}), - } - } - result = formatDiagnostics(await collectLspDiagnostics(clients, filePath!, 3_000, context.abortSignal), filePath!, context.cwd) - break - case 'codeActions': { - const actionResults = await requestAll('textDocument/codeAction', { - textDocument: { uri: uriFor(filePath!) }, - range: { start: position, end: endPosition }, - context: { - diagnostics: Array.isArray(input.diagnostics) ? input.diagnostics : [], - ...(Array.isArray(input.only) && input.only.length > 0 ? { only: input.only } : {}), - }, - }) - const resolvedActions = (await Promise.all(actionResults.map(async ({ server, result: actions }) => - (await resolveCodeActions(selectClient(clients, server)!, actions ?? [])).map((action) => ({ - ...action, - server, - ...(action.edit ? { preview: formatWorkspaceEditPreview(action.edit, context.cwd, server) } : {}), - })) - ))).flat() - result = resolvedActions - if (input.apply === true) { - const selected = selectCodeAction(resolvedActions, input.action_index) - if (!selected) return { data: 'No applicable code action returned', is_error: true } - const applied = selected.edit ? await applyWorkspaceEdit(selected.edit, context) : { changedFiles: [], lsp: undefined } - const actionClient = selectClient(clients, selected.server) - const commandResult = selected.command && typeof selected.command === 'object' && actionClient - ? await actionClient.request('workspace/executeCommand', selected.command, 15_000, context.abortSignal) - : undefined - result = { - applied: applied.changedFiles, - ...(applied.lsp ? { lsp: applied.lsp } : {}), - ...(selected.command ? { command: selected.command } : {}), - ...(commandResult !== undefined ? { commandResult } : {}), - title: selected.title, - } - } - break - } - case 'formatting': - case 'rangeFormatting': { - const formattingClient = selectFormattingClient(clients, input.server) - if (!formattingClient) return { data: 'No LSP server supports formatting', is_error: true } - const formattingOptions = await resolveFormattingOptions(filePath!, input) - const edits = await formattingClient.request(operation === 'rangeFormatting' ? 'textDocument/rangeFormatting' : 'textDocument/formatting', { - textDocument: { uri: uriFor(filePath!) }, - ...(operation === 'rangeFormatting' ? { range: { start: position, end: endPosition } } : {}), - options: formattingOptions, - }, 15_000, context.abortSignal) - if (input.apply === true) { - result = { changedFiles: await applyTextEditsWorkspace(filePath!, edits ?? [], context) } - } else { - result = { server: formattingClient.serverName, preview: edits ?? [] } - } - break - } - case 'status': - result = clients.map((candidate) => candidate.getStatus()) - break - case 'capabilities': - result = clients.map((candidate) => ({ server: candidate.serverName, capabilities: candidate.getStatus().capabilities })) - break - case 'reload': - { - const targets = typeof input.server === 'string' && input.server.trim() - ? clients.filter((candidate) => candidate.serverName === input.server.trim()) - : clients - if (targets.length === 0) return { data: 'No matching LSP server is available to reload', is_error: true } - const targetNames = new Set(targets.map((candidate) => candidate.serverName)) - await Promise.all(targets.map((candidate) => candidate.reload())) - const rebuilt = await getLspClientsForFile(context.cwd, context.toolConfig, filePath).catch(() => []) - result = { - reloaded: rebuilt - .filter((candidate) => targetNames.has(candidate.serverName)) - .map((candidate) => candidate.serverName), - } - } - break - case 'request': { - const method = typeof input.query === 'string' ? input.query.trim() : '' - if (!method) return { data: 'query is required for request', is_error: true } - const payload = typeof input.payload === 'string' - ? JSON.parse(input.payload) - : input.payload ?? (filePath ? { textDocument: { uri: uriFor(filePath!) }, ...(position ? { position } : {}) } : {}) - result = await client.request(method, payload, 15_000, context.abortSignal) - break - } - case 'applyWorkspaceEdit': { - const payload = typeof input.payload === 'string' ? JSON.parse(input.payload) : input.payload - if (!payload || typeof payload !== 'object') return { data: 'payload must be a WorkspaceEdit object', is_error: true } - result = { server: client.serverName, ...(await applyWorkspaceEdit(payload as LspWorkspaceEdit, context)) } - break - } - case 'rename': { - if ( - typeof input.new_name !== 'string' - || !input.new_name.trim() - || input.new_name.length > 512 - || /[\0\r\n]/.test(input.new_name) - ) { - return { data: 'new_name must be a non-empty symbol name without control line breaks', is_error: true } - } - const renameClient = await selectRenameClient(clients, filePath!, position!, context.abortSignal, input.server) - if (!renameClient) return { data: 'No LSP server accepted prepareRename', is_error: true } - const edit = await renameClient.request('textDocument/rename', { - textDocument: { uri: uriFor(filePath!) }, position, newName: String(input.new_name), - }, 15_000, context.abortSignal) - if (input.apply === false) { - result = { - server: renameClient.serverName, - preview: formatWorkspaceEditPreview(edit, context.cwd, renameClient.serverName), - message: `Rename preview for ${input.new_name}`, - } - } else { - const applied = await applyWorkspaceEdit(edit, context) - result = { server: renameClient.serverName, ...applied, message: `Renamed symbol to ${input.new_name}` } - } - break - } - case 'renameFile': { - const newPath = resolve(context.cwd, String(input.new_path)) - try { - assertResourcePathAllowed(newPath, context) - } catch (err: any) { - return { data: String(err?.message ?? err), is_error: true } - } - const files = [{ oldUri: uriFor(filePath!), newUri: uriFor(newPath) }] - const edits = await requestAll('workspace/willRenameFiles', { files }) - const merged = mergeRenameWorkspaceEdits( - edits, - new Map(clients.map((candidate) => [candidate.serverName, candidate.serverRole] as const)), - ) - const renameOperation: LspWorkspaceEdit = { - ...merged, - documentChanges: [ - ...(merged.documentChanges ?? []), - { kind: 'rename', oldUri: files[0]!.oldUri, newUri: files[0]!.newUri }, - ], - } - if (input.apply === false) { - result = { - servers: edits.map(({ server }) => server), - preview: formatWorkspaceEditPreview(renameOperation, context.cwd, client.serverName), - } - } else { - const applied = await applyWorkspaceEdit(renameOperation, context) - await Promise.all(clients.map((candidate) => candidate.notifyRenamedFiles(files).catch(() => undefined))) - result = { - servers: edits.map(({ server }) => server), - ...applied, - message: `Renamed ${relative(context.cwd, filePath!) || filePath} to ${relative(context.cwd, newPath) || newPath}`, - } - } - break - } - default: - return { data: `Unsupported LSP operation: ${operation}`, is_error: true } - } - - return { data: formatResult(result) } - } catch (error) { - return { - data: `LSP error: ${error instanceof Error ? error.message : String(error)}. Install/configure a language server (for TypeScript: typescript-language-server --stdio).`, - is_error: true, - } - } - }, - }) -} - -export const LSPTool = createLspTool(false) -export const LSPApplyTool = createLspTool(true) - -function uriFor(filePath: string): string { - return pathToFileURL(filePath).toString() -} - -function operationNeedsPosition(operation: string): boolean { - return locationOperations.has(operation) || [ - 'definition', - 'references', - 'implementation', - 'typeDefinition', - 'hover', - 'rename', - 'prepareRename', - 'prepareCallHierarchy', - 'codeActions', - 'rangeFormatting', - ].includes(operation) -} - -function validatePositionInput(input: Record): string | undefined { - const hasZeroBased = input.line !== undefined || input.character !== undefined - const hasLineNumber = input.line_number !== undefined - const hasSymbol = input.symbol !== undefined - if ([hasZeroBased, hasLineNumber, hasSymbol].filter(Boolean).length !== 1) { - return 'Provide exactly one position form: line+character, line_number, or symbol' - } - if (hasZeroBased && ( - !Number.isInteger(input.line) || input.line < 0 - || !Number.isInteger(input.character) || input.character < 0 - )) return 'line and character must both be non-negative integers' - if (hasLineNumber && (!Number.isInteger(input.line_number) || input.line_number < 1)) { - return 'line_number must be a positive 1-based integer' - } - if (hasSymbol && (typeof input.symbol !== 'string' || !input.symbol.trim())) { - return 'symbol must be a non-empty substring' - } -} - -export async function resolveAgentPosition(filePath: string, input: Record): Promise<{ line: number; character: number }> { - if (typeof input.line === 'number' && typeof input.character === 'number') { - return { line: input.line, character: input.character } - } - if (typeof input.line_number === 'number') return { line: input.line_number - 1, character: 0 } - const raw = String(input.symbol) - const suffix = raw.match(/#(\d+)$/) - const occurrence = suffix ? Number(suffix[1]) : 1 - const symbol = suffix ? raw.slice(0, -suffix[0].length) : raw - if (!symbol || occurrence < 1) throw new Error('symbol must use a positive occurrence suffix such as symbol#2') - const lines = (await readFile(filePath, 'utf8')).split(/\r?\n/) - let seen = 0 - for (let line = 0; line < lines.length; line += 1) { - let from = 0 - while (from <= lines[line]!.length) { - const character = lines[line]!.indexOf(symbol, from) - if (character < 0) break - seen += 1 - if (seen === occurrence) return { line, character } - from = character + Math.max(symbol.length, 1) - } - } - throw new Error(`Symbol occurrence not found: ${raw}`) -} - -export function mergeRenameWorkspaceEdits( - results: Array<{ server: string; result: LspWorkspaceEdit | null }>, - roles: ReadonlyMap = new Map(), -): LspWorkspaceEdit { - const changes: Record> = {} - const documentChanges: NonNullable = [] - for (const { server, result } of results) { - if (!result) continue - for (const [uri, edits] of Object.entries(result.changes ?? {})) { - const target = changes[uri] ?? [] - for (const edit of edits) { - const duplicate = target.find((candidate) => - JSON.stringify(candidate.range) === JSON.stringify(edit.range) - && candidate.newText === edit.newText - ) - if (duplicate) continue - const conflicts = target.filter((candidate) => rangesOverlap(candidate.range, edit.range)) - if (conflicts.length > 0) { - const incomingRole = roles.get(server) ?? 'primary' - const existingRoles = conflicts.map((candidate) => roles.get(candidate.sourceServer) ?? 'primary') - if (incomingRole === 'linter' && existingRoles.includes('primary')) continue - if (incomingRole === 'primary' && existingRoles.every((role) => role === 'linter')) { - for (const conflict of conflicts) target.splice(target.indexOf(conflict), 1) - } else { - throw new Error(`Conflicting workspace/willRenameFiles edits from ${server} for ${safeFilePath(uri)}`) - } - } - target.push({ ...edit, sourceServer: server }) - } - changes[uri] = target - } - for (const change of result.documentChanges ?? []) { - const key = JSON.stringify(change) - if (!documentChanges.some((candidate) => JSON.stringify(candidate) === key)) documentChanges.push(change) - } - } - return { - ...(Object.keys(changes).length > 0 ? { - changes: Object.fromEntries(Object.entries(changes).map(([uri, edits]) => [ - uri, - edits.map(({ sourceServer: _sourceServer, ...edit }) => edit), - ])), - } : {}), - ...(documentChanges.length > 0 ? { documentChanges } : {}), - } -} - -function rangesOverlap(left: LspTextEdit['range'], right: LspTextEdit['range']): boolean { - const compare = (a: { line: number; character: number }, b: { line: number; character: number }) => - a.line - b.line || a.character - b.character - return compare(left.start, right.end) < 0 && compare(right.start, left.end) < 0 -} - -async function executeWorkspaceDiagnosticsFallback(context: ToolContext) { - if (!context.executeNestedTool) { - return { content: 'Workspace diagnostics requires the nested Bash execution bridge', is_error: true } - } - const command = await workspaceDiagnosticsCommand(context.cwd) - if (!command) return { content: 'No supported workspace diagnostics marker was found', is_error: true } - return context.executeNestedTool({ - toolName: 'Bash', - params: { - command, - purpose: 'verification', - description: 'LSP workspace diagnostics fallback', - }, - }) -} - -export async function workspaceDiagnosticsCommand(cwd: string): Promise { - if (await exists(resolve(cwd, 'tsconfig.json'))) return 'npx tsc --noEmit' - if (await exists(resolve(cwd, 'Cargo.toml'))) return 'cargo check --message-format=short' - if (await exists(resolve(cwd, 'go.work'))) { - const modules = parseGoWorkUsePaths(await readFile(resolve(cwd, 'go.work'), 'utf8')) - return modules.length > 0 - ? `go build ${modules.map((module) => quoteShellArgument(`${module.replace(/[\\/]$/, '')}/...`)).join(' ')}` - : 'go build ./...' - } - if (await exists(resolve(cwd, 'go.mod'))) return 'go build ./...' - if (await exists(resolve(cwd, 'pyproject.toml')) || await exists(resolve(cwd, 'pyrightconfig.json'))) return 'pyright' - return undefined -} - -function parseGoWorkUsePaths(content: string): string[] { - const paths: string[] = [] - let inUseBlock = false - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.replace(/\/\/.*$/, '').trim() - if (!line) continue - if (inUseBlock) { - if (line === ')') { - inUseBlock = false - } else { - paths.push(unquoteGoPath(line)) - } - continue - } - if (line === 'use (') { - inUseBlock = true - continue - } - if (line.startsWith('use ')) paths.push(unquoteGoPath(line.slice(4).trim())) - } - // Module paths come from the repo's go.work; anything outside the plain - // module charset (spaces allowed — quoteShellArgument handles them) is a - // shell-injection vector, not a module (#198) - return [...new Set(paths.filter((p) => /^[A-Za-z0-9._/@\\ -]+$/.test(p)))] -} - -function unquoteGoPath(value: string): string { - return value.replace(/^(['"])(.*)\1$/, '$2').trim() -} - -function quoteShellArgument(value: string): string { - // Single-quote with '' escaping; POSIX double quotes leave ` and $() live (#198) - return /^[a-zA-Z0-9_./:\\-]+$/.test(value) ? value : `'${value.replace(/'/g, "''")}'` -} - -function toolResultText(result: { content?: unknown; data?: unknown }): unknown { - if (result.content !== undefined) return result.content - return result.data ?? '' -} - -async function expandDiagnosticGlob(cwd: string, pattern: string, limit: number): Promise { - const normalized = pattern.replace(/\\/g, '/') - const expression = new RegExp(`^${normalized.split('*').map(escapeRegExp).join('.*')}$`, 'i') - const output: string[] = [] - const visit = async (directory: string): Promise => { - if (output.length >= limit) return - const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) - for (const entry of entries) { - if (output.length >= limit) return - if (entry.name === 'node_modules' || entry.name === '.git') continue - const absolute = join(directory, entry.name) - if (entry.isDirectory()) await visit(absolute) - else { - const candidate = relative(cwd, absolute).replace(/\\/g, '/') - if (expression.test(candidate)) output.push(absolute) - } - } - } - await visit(resolve(cwd)) - return output -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function selectClient(clients: LspClient[], server: unknown): LspClient | undefined { - if (typeof server === 'string' && server.trim()) return clients.find((client) => client.serverName === server.trim()) - return clients[0] -} - -function selectFormattingClient(clients: LspClient[], server: unknown): LspClient | undefined { - return (typeof server === 'string' && server.trim() - ? clients.filter((client) => client.serverName === server.trim()) - : clients - ).find((client) => Boolean(client.getStatus().capabilities.documentFormattingProvider || client.getStatus().capabilities.formattingProvider)) -} - -async function resolveFormattingOptions(filePath: string, input: Record): Promise<{ tabSize: number; insertSpaces: boolean }> { - if (typeof input.tab_size === 'number' && typeof input.insert_spaces === 'boolean') { - return { tabSize: Math.max(1, Math.floor(input.tab_size)), insertSpaces: input.insert_spaces } - } - let content = '' - try { content = await readFile(filePath, 'utf8') } catch { /* server fallback */ } - const indentation = content.split(/\r?\n/).map((line) => line.match(/^( +|\t+)/)?.[1]).find(Boolean) - const insertSpaces = typeof input.insert_spaces === 'boolean' ? input.insert_spaces : !indentation?.includes('\t') - const detectedSpaces = indentation && !indentation.includes('\t') ? indentation.length : 2 - const tabSize = typeof input.tab_size === 'number' ? Math.max(1, Math.floor(input.tab_size)) : detectedSpaces - return { tabSize, insertSpaces } -} - -async function selectRenameClient( - clients: LspClient[], - filePath: string, - position: { line: number; character: number }, - signal: AbortSignal | undefined, - server: unknown, -): Promise { - const candidates = typeof server === 'string' && server.trim() - ? clients.filter((client) => client.serverName === server.trim()) - : clients - const results = await Promise.all(candidates.map(async (client) => { - try { - const prepared = await client.request('textDocument/prepareRename', { - textDocument: { uri: uriFor(filePath) }, position, - }, 15_000, signal) - return prepared ? client : undefined - } catch { - return undefined - } - })) - return results.find((client): client is LspClient => Boolean(client)) -} - -function aggregateLocations(results: Array<{ server: string; result: unknown }>): unknown[] { - const seen = new Set() - const output: unknown[] = [] - for (const { server, result } of results) { - const values = Array.isArray(result) ? result : result ? [result] : [] - for (const value of values) { - if (!value || typeof value !== 'object') continue - const record = value as Record - const uri = typeof record.uri === 'string' ? record.uri : typeof record.targetUri === 'string' ? record.targetUri : '' - const range = record.range ?? record.targetRange - const key = `${uri}|${JSON.stringify(range)}|${String(record.name ?? '')}` - if (seen.has(key)) continue - seen.add(key) - output.push({ ...record, server }) - } - } - return output.sort(compareLspResultPosition) -} - -function aggregateServerArrays(results: Array<{ server: string; result: unknown }>): unknown[] { - const seen = new Set() - const output: unknown[] = [] - for (const { server, result } of results) { - const values = Array.isArray(result) ? result : result ? [result] : [] - for (const value of values) { - if (!value || typeof value !== 'object') continue - const record = value as Record - const key = JSON.stringify([record.name, record.kind, record.uri, record.range, record.selectionRange, record.location]) - if (seen.has(key)) continue - seen.add(key) - output.push({ ...record, server }) - } - } - return output.sort(compareLspResultPosition) -} - -function compareLspResultPosition(left: unknown, right: unknown): number { - const a = left && typeof left === 'object' ? left as Record : {} - const b = right && typeof right === 'object' ? right as Record : {} - const aUri = String(a.uri ?? a.targetUri ?? a.location?.uri ?? '') - const bUri = String(b.uri ?? b.targetUri ?? b.location?.uri ?? '') - if (aUri !== bUri) return aUri.localeCompare(bUri) - const aRange = a.range ?? a.targetRange ?? a.location?.range - const bRange = b.range ?? b.targetRange ?? b.location?.range - return (aRange?.start?.line ?? 0) - (bRange?.start?.line ?? 0) - || (aRange?.start?.character ?? 0) - (bRange?.start?.character ?? 0) -} - -function firstNonEmptyServerResult(results: Array<{ server: string; result: unknown }>): unknown { - const result = results.find((entry) => entry.result !== null && entry.result !== undefined) - return result ? { server: result.server, ...(result.result && typeof result.result === 'object' ? result.result as Record : { value: result.result }) } : null -} - -function formatResult(result: unknown): string { - if (result === null || result === undefined) return 'No result' - if (typeof result === 'string') return result - if (Array.isArray(result) && result.length === 0) return 'No result' - return JSON.stringify(normalizeLocations(result), null, 2) -} - -function formatDiagnostics(diagnostics: Array<{ range: { start: { line: number; character: number }; end: { line: number; character: number } }; severity?: number; code?: string | number; source?: string; server?: string; message: string }>, filePath: string, cwd: string): string { - if (diagnostics.length === 0) return 'OK' - const severity = (value?: number) => ({ 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' }[value ?? 1] ?? 'error') - const relativePath = relative(cwd, filePath) || filePath - return diagnostics - .sort((left, right) => left.range.start.line - right.range.start.line || left.range.start.character - right.range.start.character) - .map((diagnostic) => `${relativePath}:${diagnostic.range.start.line + 1}:${diagnostic.range.start.character + 1} ${severity(diagnostic.severity)}${diagnostic.code !== undefined ? ` [${diagnostic.code}]` : ''}${diagnostic.server ? ` (${diagnostic.server}${diagnostic.source ? `:${diagnostic.source}` : ''})` : diagnostic.source ? ` (${diagnostic.source})` : ''}: ${diagnostic.message}`) - .join('\n') -} - -interface LspCodeActionValue { - title: string - kind?: string - edit?: LspWorkspaceEdit - command?: unknown - data?: unknown - isPreferred?: boolean - disabled?: { reason: string } - server?: string - preview?: LspWorkspaceEditPreview -} - -async function resolveCodeActions(client: LspClient, actions: unknown[]): Promise { - const provider = client.getStatus().capabilities.codeActionProvider - const canResolve = Boolean(provider && typeof provider === 'object' && (provider as Record).resolveProvider) - const normalized = actions.filter((value): value is LspCodeActionValue => Boolean(value && typeof value === 'object' && typeof (value as Record).title === 'string')) - if (!canResolve) return normalized - return await Promise.all(normalized.map(async (action) => { - if (action.edit || action.command || action.data === undefined) return action - try { - return await client.request('codeAction/resolve', action, 15_000) - } catch { - return action - } - })) -} - -function selectCodeAction(actions: LspCodeActionValue[], index: unknown): LspCodeActionValue | undefined { - if (typeof index === 'number' && Number.isInteger(index)) return actions[index] - return actions.find((action) => action.isPreferred && !action.disabled) ?? actions.find((action) => !action.disabled) -} - -function normalizeLocations(value: unknown): unknown { - if (Array.isArray(value)) return value.map(normalizeLocations) - if (!value || typeof value !== 'object') return value - const record = value as Record - if (typeof record.uri === 'string' && record.range) return locationSummary(record as unknown as LspLocation) - if (typeof record.targetUri === 'string' && record.targetRange) return locationLinkSummary(record as unknown as LspLocationLink) - return Object.fromEntries(Object.entries(record).map(([key, item]) => [key, normalizeLocations(item)])) -} - -function locationSummary(location: LspLocation): Record { - return { - file: safeFilePath(location.uri), - range: location.range, - ...(typeof (location as LspLocation & { server?: unknown }).server === 'string' ? { server: (location as LspLocation & { server: string }).server } : {}), - } -} - -function locationLinkSummary(location: LspLocationLink): Record { - return { - file: safeFilePath(location.targetUri), - range: location.targetSelectionRange ?? location.targetRange, - ...(typeof (location as LspLocationLink & { server?: unknown }).server === 'string' ? { server: (location as LspLocationLink & { server: string }).server } : {}), - } -} - -function safeFilePath(uri: string): string { - try { return filePathFromUri(uri) } catch { return uri } -} - -export interface LspWorkspaceEditPreview { - files: string[] - edits: number - operations: Array<{ server: string; kind: 'edit' | 'create' | 'rename' | 'delete'; path: string }> -} - -function formatWorkspaceEditPreview(edit: LspWorkspaceEdit | null | undefined, cwd: string, server = 'default'): LspWorkspaceEditPreview { - if (!edit) return { files: [], edits: 0, operations: [] } - const files = new Set() - const operations: LspWorkspaceEditPreview['operations'] = [] - let editCount = 0 - for (const [uri, fileEdits] of Object.entries(edit.changes ?? {})) { - const filePath = safeFilePath(uri) - files.add(relative(cwd, filePath) || filePath) - editCount += fileEdits.length - operations.push({ server, kind: 'edit', path: relative(cwd, filePath) || filePath }) - } - for (const change of edit.documentChanges ?? []) { - if ('textDocument' in change) { - const filePath = safeFilePath(change.textDocument.uri) - files.add(relative(cwd, filePath) || filePath) - editCount += change.edits.length - operations.push({ server, kind: 'edit', path: relative(cwd, filePath) || filePath }) - } else if (change.kind === 'create') { - const filePath = safeFilePath(change.uri) - files.add(relative(cwd, filePath) || filePath) - operations.push({ server, kind: 'create', path: relative(cwd, filePath) || filePath }) - } else if (change.kind === 'rename') { - const oldPath = safeFilePath(change.oldUri) - const newPath = safeFilePath(change.newUri) - files.add(relative(cwd, oldPath) || oldPath) - files.add(relative(cwd, newPath) || newPath) - operations.push({ server, kind: 'rename', path: `${relative(cwd, oldPath) || oldPath} -> ${relative(cwd, newPath) || newPath}` }) - } else if (change.kind === 'delete') { - const filePath = safeFilePath(change.uri) - files.add(relative(cwd, filePath) || filePath) - operations.push({ server, kind: 'delete', path: relative(cwd, filePath) || filePath }) - } - } - return { files: [...files], edits: editCount, operations } -} - -async function applyTextEditsWorkspace(filePath: string, edits: LspTextEdit[], context: ToolContext): Promise { - const absolute = resolve(context.cwd, filePath) - assertWriteAllowed(absolute, context) - const original = await readFile(absolute, 'utf8') - const updated = applyTextEdits(original, edits) - if (updated === original) return [] - await writeFileAtomic(absolute, updated) - await notifyLspFileChanged(absolute) - return [absolute] -} - -export async function applyWorkspaceEdit(edit: LspWorkspaceEdit | null | undefined, context: ToolContext) { - if (!edit) return { changedFiles: [], lsp: undefined } - type WorkspaceStep = - | { kind: 'edit'; path: string; edits: LspTextEdit[] } - | { kind: 'create'; path: string; options?: { overwrite?: boolean; ignoreIfExists?: boolean } } - | { kind: 'rename'; oldPath: string; newPath: string; options?: { overwrite?: boolean; ignoreIfExists?: boolean } } - | { kind: 'delete'; path: string; options?: { recursive?: boolean; ignoreIfNotExists?: boolean } } - - const steps: WorkspaceStep[] = [] - for (const [uri, edits] of Object.entries(edit.changes ?? {})) { - steps.push({ kind: 'edit', path: resolve(context.cwd, filePathFromUri(uri)), edits }) - } - for (const change of edit.documentChanges ?? []) { - if ('textDocument' in change) { - steps.push({ kind: 'edit', path: resolve(context.cwd, filePathFromUri(change.textDocument.uri)), edits: change.edits }) - } else if (change.kind === 'create') { - steps.push({ kind: 'create', path: resolve(context.cwd, filePathFromUri(change.uri)), options: change.options }) - } else if (change.kind === 'rename') { - steps.push({ kind: 'rename', oldPath: resolve(context.cwd, filePathFromUri(change.oldUri)), newPath: resolve(context.cwd, filePathFromUri(change.newUri)), options: change.options }) - } else { - steps.push({ kind: 'delete', path: resolve(context.cwd, filePathFromUri(change.uri)), options: change.options }) - } - } - - // First pass validates all operations and builds the final virtual workspace. - // No filesystem mutation happens until this pass completes. - const existence = new Map() - const directories = new Set() - const original = new Map() - const virtual = new Map() - const ignored = new Set() - const resourceContent = new Map() - const load = async (filePath: string): Promise => { - if (existence.has(filePath)) return existence.get(filePath)! - const info = await stat(filePath).catch(() => undefined) - const present = Boolean(info) - existence.set(filePath, present) - if (info?.isDirectory()) { - directories.add(filePath) - } else if (present) { - const content = await readFile(filePath, 'utf8') - original.set(filePath, content) - virtual.set(filePath, content) - } - return present - } - - for (const step of steps) { - if (step.kind === 'edit') { - assertWriteAllowed(step.path, context) - if (!(await load(step.path))) throw new Error(`LSP edit target does not exist: ${step.path}`) - virtual.set(step.path, applyTextEdits(virtual.get(step.path)!, step.edits)) - } else if (step.kind === 'create') { - assertResourcePathAllowed(step.path, context, step.options?.overwrite === true) - const present = await load(step.path) - if (present && step.options?.ignoreIfExists) { - ignored.add(step) - continue - } - if (present && !step.options?.overwrite) throw new Error(`LSP create target already exists: ${step.path}`) - existence.set(step.path, true) - virtual.set(step.path, '') - if (!original.has(step.path)) original.set(step.path, '') - } else if (step.kind === 'rename') { - assertResourcePathAllowed(step.oldPath, context) - assertResourcePathAllowed(step.newPath, context, step.options?.overwrite === true) - if (!(await load(step.oldPath))) throw new Error(`LSP rename source does not exist: ${step.oldPath}`) - const targetPresent = await load(step.newPath) - if (targetPresent && step.options?.ignoreIfExists) { - ignored.add(step) - continue - } - if (targetPresent && !step.options?.overwrite) throw new Error(`LSP rename target already exists: ${step.newPath}`) - resourceContent.set(step, virtual.get(step.oldPath)) - existence.set(step.oldPath, false) - existence.set(step.newPath, true) - if (directories.has(step.oldPath)) { - if (isPathInsideOrEqual(step.oldPath, step.newPath)) { - throw new Error(`LSP cannot rename a directory into itself: ${step.oldPath}`) - } - for (const [oldChildPath, content] of [...virtual.entries()]) { - if (!isPathInsideOrEqual(step.oldPath, oldChildPath) || oldChildPath === step.oldPath) continue - const newChildPath = join(step.newPath, relative(step.oldPath, oldChildPath)) - virtual.delete(oldChildPath) - virtual.set(newChildPath, content) - existence.set(oldChildPath, false) - existence.set(newChildPath, true) - if (original.has(oldChildPath)) { - original.set(newChildPath, original.get(oldChildPath)!) - original.delete(oldChildPath) - } - } - directories.delete(step.oldPath) - directories.add(step.newPath) - } else { - virtual.set(step.newPath, virtual.get(step.oldPath)!) - virtual.delete(step.oldPath) - original.set(step.newPath, original.get(step.oldPath)!) - original.delete(step.oldPath) - } - } else { - assertResourcePathAllowed(step.path, context, true) - if (!(await load(step.path))) { - if (step.options?.ignoreIfNotExists) { - ignored.add(step) - continue - } - throw new Error(`LSP delete target does not exist: ${step.path}`) - } - resourceContent.set(step, virtual.get(step.path)) - existence.set(step.path, false) - if (directories.has(step.path)) { - for (const childPath of [...virtual.keys()]) { - if (isPathInsideOrEqual(step.path, childPath)) virtual.delete(childPath) - } - } else { - virtual.delete(step.path) - } - } - } - - const lspBatch = await prepareLspWritethroughBatch({ - files: [...virtual.entries()] - .filter(([filePath, content]) => original.get(filePath) !== content || !original.has(filePath)) - .map(([filePath, content]) => ({ - filePath, - content, - existedBefore: original.has(filePath), - })), - context, - }) - for (const [filePath, content] of lspBatch.contents) virtual.set(filePath, content) - - const changedFiles: string[] = [] - const changed = new Set() - const markChanged = (filePath: string) => { - if (!changed.has(filePath)) { - changed.add(filePath) - changedFiles.push(filePath) - } - } - const flush = async (filePath: string) => { - if (!existence.get(filePath) || !virtual.has(filePath)) return - const content = virtual.get(filePath)! - if (original.get(filePath) !== content || !(await exists(filePath))) { - await mkdir(dirname(filePath), { recursive: true }) - await writeFileAtomic(filePath, content) - markChanged(filePath) - } - } - - // Apply resource operations in documentChanges order. Text edits are - // flushed immediately before a resource operation that depends on them. - for (const step of steps) { - if (ignored.has(step) || step.kind === 'edit') continue - if (step.kind === 'create') { - if (await exists(step.path) && step.options?.overwrite) await rm(step.path, { recursive: true, force: true }) - await mkdir(dirname(step.path), { recursive: true }) - await writeFileAtomic(step.path, '') - markChanged(step.path) - } else if (step.kind === 'rename') { - const renamedContent = resourceContent.get(step) - if (renamedContent !== undefined && original.get(step.newPath) !== renamedContent) { - await writeFileAtomic(step.oldPath, renamedContent) - await notifyLspFileChanged(step.oldPath) - } - if (await exists(step.newPath) && step.options?.overwrite) await rm(step.newPath, { recursive: true, force: true }) - await mkdir(dirname(step.newPath), { recursive: true }) - await notifyLspFileClosed(step.oldPath) - await rename(step.oldPath, step.newPath) - await notifyLspFileChanged(step.newPath) - markChanged(`${step.oldPath} -> ${step.newPath}`) - } else { - const deletedContent = resourceContent.get(step) - if (deletedContent !== undefined && original.get(step.path) !== deletedContent) { - await writeFileAtomic(step.path, deletedContent) - await notifyLspFileChanged(step.path) - } - await notifyLspFileClosed(step.path) - await rm(step.path, { recursive: step.options?.recursive ?? false, force: true }) - markChanged(step.path) - } - } - for (const filePath of virtual.keys()) await flush(filePath) - const lsp = await lspBatch.commit() - return { changedFiles, lsp } -} - -function assertWriteAllowed(filePath: string, context: ToolContext): void { - // Same unsafe-path screening Read/Write/Edit apply (UNC/SMB, device paths) - const unsafeReason = getUnsafeFilePathReason(filePath) - if (unsafeReason) throw new Error(unsafeReason) - const sandboxError = ensurePathAllowed(filePath, 'write', context.sandbox, context.additionalDirectories) - if (sandboxError) throw new Error(sandboxError) -} - -/** - * Resource operations (create/rename/delete) mutate whole paths, so beyond the - * write screening they must stay inside the workspace roots (#197). With - * `strictInside`, the operation may not target a root itself (recursive - * deletes and overwrite-rm of an entire workspace root are refused). - */ -function assertResourcePathAllowed(filePath: string, context: ToolContext, strictInside = false): void { - assertWriteAllowed(filePath, context) - const roots = [context.cwd, ...(context.additionalDirectories ?? [])] - const containingRoot = roots.find((root) => isPathInsideOrEqual(root, filePath)) - if (!containingRoot) { - throw new Error(`LSP resource operations must stay inside the workspace roots: ${filePath}`) - } - if (strictInside && isPathInsideOrEqual(filePath, containingRoot)) { - throw new Error(`LSP resource operation refuses to replace or delete a workspace root: ${filePath}`) - } -} - -function isPathInsideOrEqual(parentPath: string, candidatePath: string): boolean { - const child = relative(parentPath, candidatePath) - return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) -} - -async function exists(filePath: string): Promise { - try { await stat(filePath); return true } catch { return false } -} - -async function writeFileAtomic(filePath: string, content: string): Promise { - const tempPath = join(dirname(filePath), `.${basename(filePath)}.${crypto.randomUUID()}.lsp.tmp`) - try { - await writeFile(tempPath, content, 'utf8') - await rename(tempPath, filePath) - } catch (error) { - await rm(tempPath, { force: true }).catch(() => undefined) - throw error - } -} diff --git a/packages/sdk/src/tools/notebook-edit.ts b/packages/sdk/src/tools/notebook-edit.ts index 2be6eee1e..b12cfc6d0 100644 --- a/packages/sdk/src/tools/notebook-edit.ts +++ b/packages/sdk/src/tools/notebook-edit.ts @@ -13,7 +13,6 @@ import { readFile, writeFile, rename, rm, stat } from 'fs/promises' import { resolve, dirname, basename, join } from 'path' import { defineTool } from './types.js' import { ensurePathAllowed, getUnsafeFilePathReason } from '../utils/pathing.js' -import { prepareLspWritethrough } from '../lsp/writethrough.js' import { decodeTextFile, encodeTextFile } from '../utils/text-file.js' type NotebookCell = { @@ -212,10 +211,7 @@ export const NotebookEditTool = defineTool({ ensureCellIds(cells) const targetCell = cells[targetIndex] let updatedFile = JSON.stringify(notebook, null, 1) - const lsp = await prepareLspWritethrough({ filePath: notebookPath, content: updatedFile, context, existedBefore: true }) - updatedFile = lsp.content await writeFileAtomic(notebookPath, encodeTextFile(updatedFile, decoded)) - const lspResult = await lsp.commit() const updatedStat = await stat(notebookPath) context.fileStateCache?.set(notebookPath, { content: updatedFile, @@ -235,7 +231,6 @@ export const NotebookEditTool = defineTool({ }), _meta: { file: { path: notebookPath, overwritten: true, checkpointable: true, checkpointId: context.currentUserMessageId }, - lsp: lspResult, }, } } catch (err: any) { diff --git a/packages/sdk/src/tools/write.ts b/packages/sdk/src/tools/write.ts index 6459b6ead..cd098eef7 100644 --- a/packages/sdk/src/tools/write.ts +++ b/packages/sdk/src/tools/write.ts @@ -7,7 +7,6 @@ import { dirname } from 'path' import { defineTool } from './types.js' import type { ToolContext } from '../types.js' import { ensurePathAllowed, getUnsafeFilePathReason, resolveInputPath } from '../utils/pathing.js' -import { prepareLspWritethrough } from '../lsp/writethrough.js' import { decodeTextFile, encodeTextFile } from '../utils/text-file.js' import { countLineChanges } from '../utils/line-change-stats.js' import { withFileMutationLock } from '../utils/file-mutation-lock.js' @@ -98,19 +97,8 @@ export const FileWriteTool = defineTool({ } } } - const lsp = await prepareLspWritethrough({ filePath, content, context, existedBefore: overwritten }) - content = lsp.content - bytes = Buffer.byteLength(content, 'utf8') - if (bytes > maxBytes) { - return { - data: `Error: LSP-formatted content for ${filePath} exceeds the ${maxBytes}-byte limit.`, - is_error: true, - _meta: { file: { path: filePath, rejected: 'size', bytes, maxBytes } }, - } - } encoded = existingEncoding ? encodeTextFile(content, existingEncoding) : Buffer.from(content, 'utf8') await writeFileAtomic(filePath, encoded, assertWriteAllowed(context)) - const lspResult = await lsp.commit() const updated = await stat(filePath) context.fileStateCache?.set(filePath, { @@ -140,7 +128,6 @@ export const FileWriteTool = defineTool({ checkpointId: context.currentUserMessageId, ...lineChanges, }, - lsp: lspResult, }, } } catch (err: any) { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index bf9a6a313..c38ac9475 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -70,7 +70,6 @@ export type SDKMessage = | SDKStatusMessage | SDKTaskNotificationMessage | SDKMemorySavedMessage - | SDKLspDiagnosticsMessage | SDKRateLimitEvent | SDKHookStartedMessage | SDKHookProgressMessage @@ -241,46 +240,6 @@ export interface SDKRunAbortedMessage { pending_tool_calls: Array<{ id: string; name: string; input: unknown }> } -export interface SDKLspDiagnosticsMessage { - type: 'system' - subtype: 'lsp_diagnostics' - session_id?: string - tool_use_id?: string - file_path: string - mutation_version: number - sha256: string - delayed: boolean - diagnostics: LspDiagnosticBatch -} - -export interface LspDiagnosticBatch { - servers: string[] - total: number - errors: number - warnings: number - truncated: boolean - items: Array<{ - server?: string - source?: string - severity?: 1 | 2 | 3 | 4 - code?: string | number - message: string - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - }> - artifact?: FileResultRef -} - -export interface LspWritethroughResult { - servers: string[] - formatted: boolean - diagnostics?: LspDiagnosticBatch - diagnosticsDelayed: boolean - mutationVersion: number -} - export type AgentContextCompactionTrigger = 'auto' | 'manual' | 'prompt_too_long' export type AgentContextCompactionStage = 'summarizing' | 'rewriting_context' diff --git a/packages/sdk/src/utils/tool-approval.ts b/packages/sdk/src/utils/tool-approval.ts index 6eaae46eb..9c7cd558a 100644 --- a/packages/sdk/src/utils/tool-approval.ts +++ b/packages/sdk/src/utils/tool-approval.ts @@ -11,7 +11,6 @@ const TOOL_ALIASES: Record = { listdir: ['Glob', 'ls'], listmcpresources: 'ListMcpResourcesTool', listmcpresourcestool: 'ListMcpResourcesTool', - lsp: 'LSP', notebookedit: 'NotebookEdit', read: 'Read', readfile: 'Read', diff --git a/packages/shared/src/types/agent-events.test.ts b/packages/shared/src/types/agent-events.test.ts index fbe2a3f30..2aefda1e8 100644 --- a/packages/shared/src/types/agent-events.test.ts +++ b/packages/shared/src/types/agent-events.test.ts @@ -5,7 +5,6 @@ import type { BackgroundTaskNotificationDetail, CodingReportDetail, ContextCompactionDetail, - LspDiagnosticsDetail, MemoryContextUsedDetail, MessageUpdateDetail, PlanPreviewDetail, @@ -252,35 +251,6 @@ describe("batch 5 domain detail types", () => { expect(bare.summary).toBeUndefined() }) - test("LspDiagnosticsDetail fields aligned with the legacy runtime event", () => { - const d: LspDiagnosticsDetail = { - type: "lsp.diagnostics", - filePath: "src/a.ts", - mutationVersion: 2, - sha256: "abc", - delayed: false, - diagnostics: { - servers: ["tsconfig"], - total: 1, - errors: 1, - warnings: 0, - truncated: false, - items: [{ message: "oops", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } } }], - }, - } - const detail: SdkLifecycleDetail = d - expect(detail.type).toBe("lsp.diagnostics") - if (detail.type === "lsp.diagnostics") { - expect(detail.toolUseId).toBeUndefined() - expect(detail.filePath).toBe("src/a.ts") - expect(detail.mutationVersion).toBe(2) - expect(detail.sha256).toBe("abc") - expect(detail.delayed).toBe(false) - expect(detail.diagnostics.errors).toBe(1) - expect(detail.diagnostics.items).toHaveLength(1) - } - }) - test("CodingReportDetail fields and union membership", () => { const d: CodingReportDetail = { type: "coding.report", diff --git a/packages/shared/src/types/agent-events.ts b/packages/shared/src/types/agent-events.ts index 685472673..51ccdeca9 100644 --- a/packages/shared/src/types/agent-events.ts +++ b/packages/shared/src/types/agent-events.ts @@ -181,35 +181,6 @@ export interface AdvisorReviewedDetail { review: unknown } -export interface LspDiagnosticsDetail { - type: 'lsp.diagnostics' - /** Fields aligned with the legacy LspDiagnosticsUpdatedRuntimeEvent. */ - toolUseId?: string - filePath: string - mutationVersion: number - sha256: string - delayed: boolean - diagnostics: { - servers: string[] - total: number - errors: number - warnings: number - truncated: boolean - items: Array<{ - server?: string - source?: string - severity?: 1 | 2 | 3 | 4 - code?: string | number - message: string - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - }> - artifact?: unknown - } -} - export interface CodingReportDetail { type: 'coding.report' /** Legacy RuntimeCodingReport payload (T1 verdict: migrated; dual-entry with run.completed). */ @@ -229,7 +200,6 @@ export type SdkLifecycleDetail = | TodoStateDetail | TaskProgressDetail | AdvisorReviewedDetail - | LspDiagnosticsDetail | CodingReportDetail /** Result of AGENT_IPC_CHANNELS.GET_EVENTS. */ diff --git a/packages/shared/src/types/lume-config.ts b/packages/shared/src/types/lume-config.ts index aba05a176..30b83bd4c 100644 --- a/packages/shared/src/types/lume-config.ts +++ b/packages/shared/src/types/lume-config.ts @@ -210,32 +210,6 @@ export const DEFAULT_LUME_WEB_SEARCH: LumeConfigWebSearchSection = { } } -export interface LumeLspServerConfig { - disabled?: boolean - command?: string - args?: string[] - cwd?: string - fileTypes?: string[] - rootMarkers?: string[] - initOptions?: Record - settings?: Record - requestTimeoutMs?: number - warmupTimeoutMs?: number - priority?: number - role?: "primary" | "linter" -} - -export interface LumeConfigLspSection { - enabled?: boolean - lazy?: boolean - diagnosticsOnWrite?: boolean - diagnosticsDeduplicate?: boolean - formatOnWrite?: boolean - idleTimeoutMs?: number - useLspmux?: "auto" | "off" - servers?: Record -} - export interface LumeConfigSectionSet { models?: LumeConfigModelsSection agent?: LumeConfigAgentSection @@ -247,7 +221,6 @@ export interface LumeConfigSectionSet { permissions?: LumeConfigPermissionsSection hooks?: LumeConfigHooksSection webSearch?: LumeConfigWebSearchSection - lsp?: LumeConfigLspSection } export interface LumeConfigFile extends LumeConfigSectionSet { diff --git a/packages/shared/src/types/runtime-event.ts b/packages/shared/src/types/runtime-event.ts index 1f375c370..8cb535c85 100644 --- a/packages/shared/src/types/runtime-event.ts +++ b/packages/shared/src/types/runtime-event.ts @@ -37,7 +37,6 @@ export type RuntimeEventType = | "context.compaction.started" | "context.compaction.progress" | "context.compaction.completed" - | "lsp.diagnostics.updated" | "advisor.reviewed" | "usage.updated"; @@ -335,13 +334,6 @@ export interface RuntimeCodingReport { /** Verification commands observed or selected by the runtime for this Turn. */ verificationRecords?: CodingVerificationRecord[]; recommendedVerificationCommands?: string[]; - lspDiagnostics?: { - files: string[]; - total: number; - errors: number; - warnings: number; - updatedAt: string; - }; gitActions?: CodingGitAction[]; review?: CodingReviewSummary; } @@ -975,34 +967,6 @@ export interface AdvisorReviewedRuntimeEvent extends RuntimeEventBase { durationMs?: number; } -export interface LspDiagnosticsUpdatedRuntimeEvent extends RuntimeEventBase { - type: "lsp.diagnostics.updated"; - toolUseId?: string; - filePath: string; - mutationVersion: number; - sha256: string; - delayed: boolean; - diagnostics: { - servers: string[]; - total: number; - errors: number; - warnings: number; - truncated: boolean; - items: Array<{ - server?: string; - source?: string; - severity?: 1 | 2 | 3 | 4; - code?: string | number; - message: string; - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; - }>; - artifact?: FileResultRef; - }; -} - export type LumeRuntimeEvent = | RunStartedRuntimeEvent | UserMessageSubmittedRuntimeEvent @@ -1035,6 +999,5 @@ export type LumeRuntimeEvent = | ContextCompactionStartedRuntimeEvent | ContextCompactionProgressRuntimeEvent | ContextCompactionCompletedRuntimeEvent - | LspDiagnosticsUpdatedRuntimeEvent | AdvisorReviewedRuntimeEvent | UsageUpdatedRuntimeEvent; diff --git a/packages/website/src/content/docs/en/concepts.md b/packages/website/src/content/docs/en/concepts.md index 32e732baa..2d6822565 100644 --- a/packages/website/src/content/docs/en/concepts.md +++ b/packages/website/src/content/docs/en/concepts.md @@ -31,7 +31,7 @@ Each Skill is a `SKILL.md` prompt template with hot reloading — edits take eff ## Toolset -The full toolset available to agents: file system (Read / Write / Edit / Glob / Grep), Bash (timeout control + background execution), LSP code intelligence, Office documents (create/edit docx / pptx / xlsx / pdf plus OOXML repair), web search & fetch, and image generation. +The full toolset available to agents: file system (Read / Write / Edit / Glob / Grep), Bash (timeout control + background execution), Office documents (create/edit docx / pptx / xlsx / pdf plus OOXML repair), web search & fetch, and image generation. ## Automation & IM diff --git a/packages/website/src/content/docs/zh/concepts.md b/packages/website/src/content/docs/zh/concepts.md index a0e256ccb..3a88dfea2 100644 --- a/packages/website/src/content/docs/zh/concepts.md +++ b/packages/website/src/content/docs/zh/concepts.md @@ -31,7 +31,7 @@ Lume 内置 11 位有独立风格与专长的角色——开发者、作家、 ## 工具集 -Agent 可用的完整工具:文件系统(Read / Write / Edit / Glob / Grep)、Bash(超时控制 + 后台执行)、LSP 代码智能、Office 文档(docx / pptx / xlsx / pdf 的创建编辑与 OOXML 修复)、Web 搜索与抓取、图片生成。 +Agent 可用的完整工具:文件系统(Read / Write / Edit / Glob / Grep)、Bash(超时控制 + 后台执行)、Office 文档(docx / pptx / xlsx / pdf 的创建编辑与 OOXML 修复)、Web 搜索与抓取、图片生成。 ## 自动化与 IM diff --git a/packages/website/src/i18n/ui.ts b/packages/website/src/i18n/ui.ts index 94f09f672..cc871233b 100644 --- a/packages/website/src/i18n/ui.ts +++ b/packages/website/src/i18n/ui.ts @@ -36,7 +36,7 @@ export const ui = { 'features.skills.title': 'Skills 与 MCP', 'features.skills.desc': 'SKILL.md 热加载的技能体系 + 标准 MCP 客户端,能力无限扩展。', 'features.tools.title': '完整工具集', - 'features.tools.desc': '文件系统、Bash、LSP 代码智能、Office 文档、Web 搜索与抓取、图片生成。', + 'features.tools.desc': '文件系统、Bash、Office 文档、Web 搜索与抓取、图片生成。', 'features.automation.title': '自动化', 'features.automation.desc': 'cron 定时任务与每日日程,到点自动执行并把结果推送到指定渠道。', 'features.im.title': 'IM 渠道', @@ -114,7 +114,7 @@ export const ui = { 'features.skills.title': 'Skills & MCP', 'features.skills.desc': 'Hot-reloading SKILL.md skill system plus a standard MCP client — extend it endlessly.', 'features.tools.title': 'Full Toolset', - 'features.tools.desc': 'File system, Bash, LSP code intelligence, Office documents, web search & fetch, image generation.', + 'features.tools.desc': 'File system, Bash, Office documents, web search & fetch, image generation.', 'features.automation.title': 'Automation', 'features.automation.desc': 'Cron jobs and daily schedules run on time and push results to your channels.', 'features.im.title': 'IM Channels',