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

@@ -3,13 +3,13 @@ import { exec } from "child_process"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
import { Permission } from "../permission"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { Wildcard } from "../util/wildcard"
import { $ } from "bun"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
const MAX_OUTPUT_LENGTH = 30_000
@@ -56,7 +56,6 @@ export const BashTool = Tool.define("bash", {
}),
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
const app = App.info()
const tree = await parser().then((p) => p.parse(params.command))
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
@@ -88,9 +87,9 @@ export const BashTool = Tool.define("bash", {
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved && !Filesystem.contains(app.path.cwd, resolved)) {
if (resolved && !Filesystem.contains(Instance.directory, resolved)) {
throw new Error(
`This command references paths outside of ${app.path.cwd} so it is not allowed to be executed.`,
`This command references paths outside of ${Instance.directory} so it is not allowed to be executed.`,
)
}
}
@@ -123,7 +122,7 @@ export const BashTool = Tool.define("bash", {
}
const process = exec(params.command, {
cwd: app.path.cwd,
cwd: Instance.directory,
signal: ctx.abort,
timeout,
})

View File

@@ -10,11 +10,11 @@ import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
import { Permission } from "../permission"
import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
export const EditTool = Tool.define("edit", {
@@ -34,9 +34,8 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}
const app = App.info()
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
if (!Filesystem.contains(app.path.cwd, filePath)) {
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
throw new Error(`File ${filePath} is not in the current working directory`)
}
@@ -123,7 +122,7 @@ export const EditTool = Tool.define("edit", {
diagnostics,
diff,
},
title: `${path.relative(app.path.root, filePath)}`,
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
},

View File

@@ -1,9 +1,9 @@
import { z } from "zod"
import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
@@ -17,9 +17,8 @@ export const GlobTool = Tool.define("glob", {
),
}),
async execute(params) {
const app = App.info()
let search = params.path ?? app.path.cwd
search = path.isAbsolute(search) ? search : path.resolve(app.path.cwd, search)
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
const limit = 100
const files = []
@@ -55,7 +54,7 @@ export const GlobTool = Tool.define("glob", {
}
return {
title: path.relative(app.path.root, search),
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,

View File

@@ -1,9 +1,9 @@
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
export const GrepTool = Tool.define("grep", {
description: DESCRIPTION,
@@ -17,8 +17,7 @@ export const GrepTool = Tool.define("grep", {
throw new Error("pattern is required")
}
const app = App.info()
const searchPath = params.path || app.path.cwd
const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath()
const args = ["-n", params.pattern]

View File

@@ -1,8 +1,8 @@
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -40,8 +40,7 @@ export const ListTool = Tool.define("list", {
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params) {
const app = App.info()
const searchPath = path.resolve(app.path.cwd, params.path || ".")
const searchPath = path.resolve(Instance.directory, params.path || ".")
const glob = new Bun.Glob("**/*")
const files = []
@@ -102,7 +101,7 @@ export const ListTool = Tool.define("list", {
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(app.path.root, searchPath),
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,

View File

@@ -2,8 +2,8 @@ import { z } from "zod"
import { Tool } from "./tool"
import path from "path"
import { LSP } from "../lsp"
import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt"
import { Instance } from "../project/instance"
export const LspDiagnosticTool = Tool.define("lsp_diagnostics", {
description: DESCRIPTION,
@@ -11,13 +11,12 @@ export const LspDiagnosticTool = Tool.define("lsp_diagnostics", {
path: z.string().describe("The path to the file to get diagnostics."),
}),
execute: async (args) => {
const app = App.info()
const normalized = path.isAbsolute(args.path) ? args.path : path.join(app.path.cwd, args.path)
const normalized = path.isAbsolute(args.path) ? args.path : path.join(Instance.directory, args.path)
await LSP.touchFile(normalized, true)
const diagnostics = await LSP.diagnostics()
const file = diagnostics[normalized]
return {
title: path.relative(app.path.root, normalized),
title: path.relative(Instance.worktree, normalized),
metadata: {
diagnostics,
},

View File

@@ -2,8 +2,8 @@ import { z } from "zod"
import { Tool } from "./tool"
import path from "path"
import { LSP } from "../lsp"
import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt"
import { Instance } from "../project/instance"
export const LspHoverTool = Tool.define("lsp_hover", {
description: DESCRIPTION,
@@ -13,8 +13,7 @@ export const LspHoverTool = Tool.define("lsp_hover", {
character: z.number().describe("The character number to get diagnostics."),
}),
execute: async (args) => {
const app = App.info()
const file = path.isAbsolute(args.file) ? args.file : path.join(app.path.cwd, args.file)
const file = path.isAbsolute(args.file) ? args.file : path.join(Instance.directory, args.file)
await LSP.touchFile(file, true)
const result = await LSP.hover({
...args,
@@ -22,7 +21,7 @@ export const LspHoverTool = Tool.define("lsp_hover", {
})
return {
title: path.relative(app.path.root, file) + ":" + args.line + ":" + args.character,
title: path.relative(Instance.worktree, file) + ":" + args.line + ":" + args.character,
metadata: {
result,
},

View File

@@ -3,7 +3,7 @@ import { Tool } from "./tool"
import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { App } from "../app/app"
import { Instance } from "../project/instance"
export const MultiEditTool = Tool.define("multiedit", {
description: DESCRIPTION,
@@ -35,9 +35,8 @@ export const MultiEditTool = Tool.define("multiedit", {
)
results.push(result)
}
const app = App.info()
return {
title: path.relative(app.path.root, params.filePath),
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},

View File

@@ -5,8 +5,8 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -23,8 +23,7 @@ export const ReadTool = Tool.define("read", {
if (!path.isAbsolute(filepath)) {
filepath = path.join(process.cwd(), filepath)
}
const app = App.info()
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(app.path.cwd, filepath)) {
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
throw new Error(`File ${filepath} is not in the current working directory`)
}
@@ -77,7 +76,7 @@ export const ReadTool = Tool.define("read", {
FileTime.read(ctx.sessionID, filepath)
return {
title: path.relative(App.info().path.root, filepath),
title: path.relative(Instance.worktree, filepath),
output,
metadata: {
preview,

View File

@@ -51,11 +51,13 @@ export const TaskTool = Tool.define("task", async () => {
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
const result = await Session.prompt({
messageID,
sessionID: session.id,
modelID: model.modelID,
providerID: model.providerID,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
@@ -75,9 +77,9 @@ export const TaskTool = Tool.define("task", async () => {
return {
title: params.description,
metadata: {
summary: result.parts.filter((x) => x.type === "tool"),
summary: result.parts.filter((x: any) => x.type === "tool"),
},
output: result.parts.findLast((x) => x.type === "text")?.text ?? "",
output: (result.parts.findLast((x: any) => x.type === "text") as any)?.text ?? "",
}
},
}

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { App } from "../app/app"
import { Instance } from "../project/instance"
const TodoInfo = z.object({
content: z.string().describe("Brief description of the task"),
@@ -11,12 +11,14 @@ const TodoInfo = z.object({
})
type TodoInfo = z.infer<typeof TodoInfo>
const state = App.state("todo-tool", () => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
})
const state = Instance.state(
() => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
},
)
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,

View File

@@ -4,11 +4,11 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { Permission } from "../permission"
import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
export const WriteTool = Tool.define("write", {
@@ -18,9 +18,8 @@ export const WriteTool = Tool.define("write", {
content: z.string().describe("The content to write to the file"),
}),
async execute(params, ctx) {
const app = App.info()
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
if (!Filesystem.contains(app.path.cwd, filepath)) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filepath)) {
throw new Error(`File ${filepath} is not in the current working directory`)
}
@@ -62,7 +61,7 @@ export const WriteTool = Tool.define("write", {
}
return {
title: path.relative(app.path.root, filepath),
title: path.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,