From a9c89da568e9ffab1471e4be5d3981781791537a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:40:33 -0400 Subject: [PATCH 1/2] refactor: remove ambient instance reads from lsp --- packages/opencode/src/lsp/client.ts | 7 ++- packages/opencode/src/lsp/lsp.ts | 22 +++++---- packages/opencode/src/lsp/server.ts | 74 ++++++++++++++--------------- 3 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 59a64ca1ed2e..663aed43134b 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,7 +11,6 @@ import z from "zod" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" -import { Instance } from "../project/instance" import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 @@ -39,7 +38,7 @@ export const Event = { ), } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { +export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -146,7 +145,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, notify: { async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + input.path = path.isAbsolute(input.path) ? input.path : path.resolve(input.directory, input.path) const text = await Filesystem.readText(input.path) const extension = path.extname(input.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" @@ -208,7 +207,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, async waitForDiagnostics(input: { path: string }) { const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + path.isAbsolute(input.path) ? input.path : path.resolve(input.directory, input.path), ) log.info("waiting for diagnostics", { path: normalizedPath }) let unsub: () => void diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 43c830987010..221385d26599 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" import z from "zod" import { Config } from "../config" -import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" const log = Log.create({ service: "lsp" }) @@ -162,7 +162,7 @@ export const layer = Layer.effect( const config = yield* Config.Service const state = yield* InstanceState.make( - Effect.fn("LSP.state")(function* () { + Effect.fn("LSP.state")(function* (ctx) { const cfg = yield* config.get() const servers: Record = {} @@ -187,7 +187,7 @@ export const layer = Layer.effect( servers[name] = { ...existing, id: name, - root: existing?.root ?? (async () => Instance.directory), + root: existing?.root ?? (async (_file, ctx) => ctx.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => ({ process: lspspawn(item.command[0], item.command.slice(1), { @@ -225,7 +225,10 @@ export const layer = Layer.effect( ) const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const ctx = yield* InstanceState.context + if (!AppFileSystem.contains(ctx.directory, file) && (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))) { + return [] as LSPClient.Info[] + } const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file @@ -233,7 +236,7 @@ export const layer = Layer.effect( async function schedule(server: LSPServer.Info, root: string, key: string) { const handle = await server - .spawn(root) + .spawn(root, ctx) .then((value) => { if (!value) s.broken.add(key) return value @@ -251,6 +254,7 @@ export const layer = Layer.effect( serverID: server.id, server: handle, root, + directory: ctx.directory, }).catch(async (err) => { s.broken.add(key) await Process.stop(handle.process) @@ -273,7 +277,7 @@ export const layer = Layer.effect( for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) + const root = await server.root(file, ctx) if (!root) continue if (s.broken.has(root + server.id)) continue @@ -326,13 +330,14 @@ export const layer = Layer.effect( }) const status = Effect.fn("LSP.status")(function* () { + const ctx = yield* InstanceState.context const s = yield* InstanceState.get(state) const result: Status[] = [] for (const client of s.clients) { result.push({ id: client.serverID, name: s.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), + root: path.relative(ctx.directory, client.root), status: "connected", }) } @@ -340,12 +345,13 @@ export const layer = Layer.effect( }) const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const ctx = yield* InstanceState.context const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) + const root = await server.root(file, ctx) if (!root) continue if (s.broken.has(root + server.id)) continue return true diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 760e8eaba0e4..cbd053072d92 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -6,7 +6,7 @@ import { Log } from "../util" import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "../util" -import { Instance } from "../project/instance" +import type { InstanceContext } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util" import { Process } from "../util" @@ -29,15 +29,15 @@ export interface Handle { initialization?: Record } -type RootFunction = (file: string) => Promise +type RootFunction = (file: string, ctx: InstanceContext) => Promise const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { - return async (file) => { + return async (file, ctx) => { if (excludePatterns) { const excludedFiles = Filesystem.up({ targets: excludePatterns, start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const excluded = await excludedFiles.next() await excludedFiles.return() @@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo const files = Filesystem.up({ targets: includePatterns, start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const first = await files.next() await files.return() - if (!first.value) return Instance.directory + if (!first.value) return ctx.directory return path.dirname(first.value) } } @@ -60,16 +60,16 @@ export interface Info { extensions: string[] global?: boolean root: RootFunction - spawn(root: string): Promise + spawn(root: string, ctx: InstanceContext): Promise } export const Deno: Info = { id: "deno", - root: async (file) => { + root: async (file, ctx) => { const files = Filesystem.up({ targets: ["deno.json", "deno.jsonc"], start: path.dirname(file), - stop: Instance.directory, + stop: ctx.directory, }) const first = await files.next() await files.return() @@ -98,8 +98,8 @@ export const Typescript: Info = { ["deno.json", "deno.jsonc"], ), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + async spawn(root, ctx) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) log.info("typescript server", { tsserver }) if (!tsserver) return const bin = await Npm.which("typescript-language-server") @@ -154,8 +154,8 @@ export const ESLint: Info = { id: "eslint", root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root) { - const eslint = Module.resolve("eslint", Instance.directory) + async spawn(root, ctx) { + const eslint = Module.resolve("eslint", ctx.directory) if (!eslint) return log.info("spawning eslint server") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") @@ -219,7 +219,7 @@ export const Oxlint: Info = { "package.json", ]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - async spawn(root) { + async spawn(root, ctx) { const ext = process.platform === "win32" ? ".cmd" : "" const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) @@ -232,7 +232,7 @@ export const Oxlint: Info = { const candidates = Filesystem.up({ targets: [target], start: root, - stop: Instance.worktree, + stop: ctx.worktree, }) const first = await candidates.next() await candidates.return() @@ -344,10 +344,10 @@ export const Biome: Info = { export const Gopls: Info = { id: "gopls", - root: async (file) => { - const work = await NearestRoot(["go.work"])(file) + root: async (file, ctx) => { + const work = await NearestRoot(["go.work"])(file, ctx) if (work) return work - return NearestRoot(["go.mod", "go.sum"])(file) + return NearestRoot(["go.mod", "go.sum"])(file, ctx) }, extensions: [".go"], async spawn(root) { @@ -834,7 +834,7 @@ export const RustAnalyzer: Info = { currentDir = parentDir // Stop if we've gone above the app root - if (!currentDir.startsWith(Instance.worktree)) break + if (!currentDir.startsWith(ctx.worktree)) break } return crateRoot @@ -1031,8 +1031,8 @@ export const Astro: Info = { id: "astro", extensions: [".astro"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + async spawn(root, ctx) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) if (!tsserver) { log.info("typescript not found, required for Astro language server") return @@ -1067,7 +1067,7 @@ export const Astro: Info = { export const JDTLS: Info = { id: "jdtls", - root: async (file) => { + root: async (file, ctx) => { // Without exclusions, NearestRoot defaults to instance directory so we can't // distinguish between a) no project found and b) project found at instance dir. // So we can't choose the root from (potential) monorepo markers first. @@ -1080,9 +1080,9 @@ export const JDTLS: Info = { NearestRoot( ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos, - )(file), - NearestRoot(gradleMarkers, settingsMarkers)(file), - NearestRoot(settingsMarkers)(file), + )(file, ctx), + NearestRoot(gradleMarkers, settingsMarkers)(file, ctx), + NearestRoot(settingsMarkers)(file, ctx), ]) // If projectRoot is undefined we know we are in a monorepo or no project at all. @@ -1189,18 +1189,18 @@ export const JDTLS: Info = { export const KotlinLS: Info = { id: "kotlin-ls", extensions: [".kt", ".kts"], - root: async (file) => { + root: async (file, ctx) => { // 1) Nearest Gradle root (multi-project or included build) - const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) + const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx) if (settingsRoot) return settingsRoot // 2) Gradle wrapper (strong root signal) - const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) + const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx) if (wrapperRoot) return wrapperRoot // 3) Single-project or module-level build - const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) + const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx) if (buildRoot) return buildRoot // 4) Maven fallback - return NearestRoot(["pom.xml"])(file) + return NearestRoot(["pom.xml"])(file, ctx) }, async spawn(root) { const distPath = path.join(Global.Path.bin, "kotlin-ls") @@ -1539,7 +1539,7 @@ export const Ocaml: Info = { export const BashLS: Info = { id: "bash", extensions: [".sh", ".bash", ".zsh", ".ksh"], - root: async () => Instance.directory, + root: async (_file, ctx) => ctx.directory, async spawn(root) { let binary = which("bash-language-server") const args: string[] = [] @@ -1734,7 +1734,7 @@ export const TexLab: Info = { export const DockerfileLS: Info = { id: "dockerfile", extensions: [".dockerfile", "Dockerfile"], - root: async () => Instance.directory, + root: async (_file, ctx) => ctx.directory, async spawn(root) { let binary = which("docker-langserver") const args: string[] = [] @@ -1799,16 +1799,16 @@ export const Clojure: Info = { export const Nixd: Info = { id: "nixd", extensions: [".nix"], - root: async (file) => { + root: async (file, ctx) => { // First, look for flake.nix - the most reliable Nix project root indicator - const flakeRoot = await NearestRoot(["flake.nix"])(file) - if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot + const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx) + if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot // If no flake.nix, fall back to git repository root - if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree + if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree // Finally, use the instance directory as fallback - return Instance.directory + return ctx.directory }, async spawn(root) { const nixd = which("nixd") From 618cbb9a42968a28c7cf2109969ec65eb8b1d332 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 23:43:02 -0400 Subject: [PATCH 2/2] fix: address lsp instance-context typecheck regressions --- packages/opencode/src/lsp/client.ts | 36 +++++++++++------------ packages/opencode/src/lsp/server.ts | 4 +-- packages/opencode/test/lsp/client.test.ts | 3 ++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 663aed43134b..b20e8ae7f00c 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -144,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle return connection }, notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(input.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) + async open(request: { path: string }) { + request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path) + const text = await Filesystem.readText(request.path) + const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[input.path] + const version = files[request.path] if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) + log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, type: 2, // Changed }, ], }) const next = version + 1 - files[input.path] = next + files[request.path] = next log.info("textDocument/didChange", { - path: input.path, + path: request.path, version: next, }) await connection.sendNotification("textDocument/didChange", { textDocument: { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, version: next, }, contentChanges: [{ text }], @@ -178,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle return } - log.info("workspace/didChangeWatchedFiles", input) + log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, type: 1, // Created }, ], }) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) + log.info("textDocument/didOpen", request) + diagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { - uri: pathToFileURL(input.path).href, + uri: pathToFileURL(request.path).href, languageId, version: 0, text, }, }) - files[input.path] = 0 + files[request.path] = 0 return }, }, get diagnostics() { return diagnostics }, - async waitForDiagnostics(input: { path: string }) { + async waitForDiagnostics(request: { path: string }) { const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(input.directory, input.path), + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) log.info("waiting for diagnostics", { path: normalizedPath }) let unsub: () => void diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index cbd053072d92..e0221c9dd57f 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -810,8 +810,8 @@ export const SourceKit: Info = { export const RustAnalyzer: Info = { id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) + root: async (file, ctx) => { + const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx) if (crateRoot === undefined) { return undefined } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f124fddf9581..d6eaa317f945 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -31,6 +31,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), }) @@ -55,6 +56,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), }) @@ -79,6 +81,7 @@ describe("LSPClient interop", () => { serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), + directory: process.cwd(), }), })