Refactor to support multiple instances inside single opencode process (#2360)

This release has a bunch of minor breaking changes if you are using opencode plugins or sdk

1. storage events have been removed (we might bring this back but had some issues)
2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project
3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo
4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object)
5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
This commit is contained in:
Dax
2025-09-01 17:15:49 -04:00
committed by GitHub
parent e2df3eb44d
commit f993541e0b
112 changed files with 4303 additions and 3159 deletions

View File

@@ -1,7 +1,6 @@
import path from "path"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { App } from "../app/app"
import { Log } from "../util/log"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
@@ -9,6 +8,7 @@ import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -35,7 +35,6 @@ export namespace LSPClient {
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const app = App.info()
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
@@ -130,7 +129,7 @@ export namespace LSPClient {
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const extension = path.extname(input.path)
@@ -169,7 +168,7 @@ export namespace LSPClient {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
return await withTimeout(

View File

@@ -1,4 +1,3 @@
import { App } from "../app/app"
import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
@@ -6,6 +5,7 @@ import { LSPServer } from "./server"
import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -53,8 +53,7 @@ export namespace LSP {
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = App.state(
"lsp",
const state = Instance.state(
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = {}
@@ -71,9 +70,9 @@ export namespace LSP {
}
servers[name] = {
...existing,
root: existing?.root ?? (async (_file, app) => app.path.root),
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing.extensions,
spawn: async (_app, root) => {
spawn: async (root) => {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
@@ -117,7 +116,7 @@ export namespace LSP {
const result: LSPClient.Info[] = []
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file, App.info())
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
@@ -126,7 +125,7 @@ export namespace LSP {
result.push(match)
continue
}
const handle = await server.spawn(App.info(), root).catch((err) => {
const handle = await server.spawn(root).catch((err) => {
s.broken.add(root + server.id)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined

View File

@@ -1,5 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
import type { App } from "../app/app"
import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -7,6 +6,7 @@ import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
export namespace LSPServer {
@@ -17,18 +17,18 @@ export namespace LSPServer {
initialization?: Record<string, any>
}
type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
type RootFunction = (file: string) => Promise<string | undefined>
const NearestRoot = (patterns: string[]): RootFunction => {
return async (file, app) => {
return async (file) => {
const files = Filesystem.up({
targets: patterns,
start: path.dirname(file),
stop: app.path.root,
stop: Instance.worktree,
})
const first = await files.next()
await files.return()
if (!first.value) return app.path.root
if (!first.value) return Instance.worktree
return path.dirname(first.value)
}
}
@@ -38,15 +38,15 @@ export namespace LSPServer {
extensions: string[]
global?: boolean
root: RootFunction
spawn(app: App.Info, root: string): Promise<Handle | undefined>
spawn(root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
async spawn(root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root,
@@ -83,7 +83,7 @@ export namespace LSPServer {
"nuxt.config.js",
"vue.config.js",
]),
async spawn(_, root) {
async spawn(root) {
let binary = Bun.which("vue-language-server")
const args: string[] = []
if (!binary) {
@@ -145,8 +145,8 @@ export namespace LSPServer {
"package.json",
]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(app, root) {
const eslint = await Bun.resolve("eslint", app.path.cwd).catch(() => {})
async spawn(root) {
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -194,13 +194,13 @@ export namespace LSPServer {
export const Gopls: Info = {
id: "gopls",
root: async (file, app) => {
const work = await NearestRoot(["go.work"])(file, app)
root: async (file) => {
const work = await NearestRoot(["go.work"])(file)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file, app)
return NearestRoot(["go.mod", "go.sum"])(file)
},
extensions: [".go"],
async spawn(_, root) {
async spawn(root) {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -238,7 +238,7 @@ export namespace LSPServer {
id: "ruby-lsp",
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(_, root) {
async spawn(root) {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -279,7 +279,7 @@ export namespace LSPServer {
id: "pyright",
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(_, root) {
async spawn(root) {
let binary = Bun.which("pyright-langserver")
const args = []
if (!binary) {
@@ -333,7 +333,7 @@ export namespace LSPServer {
id: "elixir-ls",
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(_, root) {
async spawn(root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -389,7 +389,7 @@ export namespace LSPServer {
id: "zls",
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(_, root) {
async spawn(root) {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -495,7 +495,7 @@ export namespace LSPServer {
id: "csharp",
root: NearestRoot([".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(_, root) {
async spawn(root) {
let bin = Bun.which("csharp-ls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -533,8 +533,8 @@ export namespace LSPServer {
export const RustAnalyzer: Info = {
id: "rust",
root: async (file, app) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, app)
root: async (root) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
if (crateRoot === undefined) {
return undefined
}
@@ -557,13 +557,13 @@ export namespace LSPServer {
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(app.path.root)) break
if (!currentDir.startsWith(Instance.worktree)) break
}
return crateRoot
},
extensions: [".rs"],
async spawn(_, root) {
async spawn(root) {
const bin = Bun.which("rust-analyzer")
if (!bin) {
log.info("rust-analyzer not found in path, please install it")
@@ -581,7 +581,7 @@ export namespace LSPServer {
id: "clangd",
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(_, root) {
async spawn(root) {
let bin = Bun.which("clangd", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})