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,10 +3,10 @@ import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import { App } from "../app/app"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
export namespace File {
const log = Log.create({ service: "file" })
@@ -46,10 +46,10 @@ export namespace File {
}
export async function status() {
const app = App.info()
if (!app.git) return []
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
const changedFiles: Info[] = []
@@ -66,13 +66,17 @@ export namespace File {
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`.cwd(app.path.cwd).quiet().nothrow().text()
const untrackedOutput = await $`git ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(app.path.root, filepath)).text()
const content = await Bun.file(path.join(Instance.worktree, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
@@ -87,7 +91,11 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -103,23 +111,23 @@ export namespace File {
return changedFiles.map((x) => ({
...x,
path: path.relative(app.path.cwd, path.join(app.path.root, x.path)),
path: path.relative(Instance.directory, path.join(Instance.worktree, x.path)),
}))
}
export async function read(file: string) {
using _ = log.time("read", { file })
const app = App.info()
const full = path.join(app.path.cwd, file)
const project = Instance.project
const full = path.join(Instance.directory, file)
const content = await Bun.file(full)
.text()
.catch(() => "")
.then((x) => x.trim())
if (app.git) {
const rel = path.relative(app.path.root, full)
const diff = await $`git diff ${rel}`.cwd(app.path.root).quiet().nothrow().text()
if (project.vcs === "git") {
const rel = path.relative(Instance.worktree, full)
const diff = await $`git diff ${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${rel}`.cwd(app.path.root).quiet().nothrow().text()
const original = await $`git show HEAD:${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
})
@@ -131,22 +139,22 @@ export namespace File {
export async function list(dir?: string) {
const exclude = [".git", ".DS_Store"]
const app = App.info()
const project = Instance.project
let ignored = (_: string) => false
if (app.git) {
const gitignore = Bun.file(path.join(app.path.root, ".gitignore"))
if (project.vcs === "git") {
const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
if (await gitignore.exists()) {
const ig = ignore().add(await gitignore.text())
ignored = ig.ignores.bind(ig)
}
}
const resolved = dir ? path.join(app.path.cwd, dir) : app.path.cwd
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
const nodes: Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(app.path.cwd, fullPath)
const relativeToRoot = path.relative(app.path.root, fullPath)
const relativePath = path.relative(Instance.directory, fullPath)
const relativeToRoot = path.relative(Instance.worktree, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,

View File

@@ -1,18 +1,20 @@
import { App } from "../app/app"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
[path: string]: Date | undefined
export const state = Instance.state(
() => {
const read: {
[sessionID: string]: {
[path: string]: Date | undefined
}
} = {}
return {
read,
}
} = {}
return {
read,
}
})
},
)
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })

View File

@@ -1,9 +1,9 @@
import { z } from "zod"
import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Instance } from "../project/instance"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -17,22 +17,16 @@ export namespace FileWatcher {
}),
),
}
const state = App.state(
"file.watcher",
const state = Instance.state(
() => {
const app = App.use()
if (!app.info.git) return {}
if (Instance.project.vcs !== "git") return {}
try {
const watcher = fs.watch(app.info.path.cwd, { recursive: true }, (event, file) => {
const watcher = fs.watch(Instance.directory, { recursive: true }, (event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
Bus.publish(Event.Updated, {
file,
event,
})
})
return { watcher }