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

@@ -5,25 +5,26 @@ import { Global } from "../../global"
import { Agent } from "../../agent/agent"
import path from "path"
import matter from "gray-matter"
import { App } from "../../app/app"
import { Instance } from "../../project/instance"
const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
async handler() {
await App.provide({ cwd: process.cwd() }, async (app) => {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Create agent")
const project = Instance.project
let scope: "global" | "project" = "global"
if (app.git) {
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: app.path.root,
hint: Instance.worktree,
},
{
label: "Global",
@@ -116,7 +117,7 @@ const AgentCreateCommand = cmd({
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(app.path.root, ".opencode"),
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
`agent`,
`${generated.identifier}.md`,
)

View File

@@ -8,7 +8,7 @@ import path from "path"
import os from "os"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { App } from "../../app/app"
import { Instance } from "../../project/instance"
export const AuthCommand = cmd({
command: "auth",
@@ -74,7 +74,7 @@ export const AuthLoginCommand = cmd({
type: "string",
}),
async handler(args) {
await App.provide({ cwd: process.cwd() }, async () => {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {

View File

@@ -1,20 +0,0 @@
import { App } from "../../../app/app"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const AppInfoCommand = cmd({
command: "info",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(JSON.stringify(app, null, 2))
})
},
})
export const AppCommand = cmd({
command: "app",
builder: (yargs) => yargs.command(AppInfoCommand).demandCommand(),
async handler() {},
})

View File

@@ -11,7 +11,7 @@ const FileReadCommand = cmd({
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
const content = await File.read(args.path)
console.log(content)
})
@@ -22,7 +22,7 @@ const FileStatusCommand = cmd({
command: "status",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
})
@@ -38,7 +38,7 @@ const FileListCommand = cmd({
description: "File path to list",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
const files = await File.list(args.path)
console.log(JSON.stringify(files, null, 2))
})

View File

@@ -1,7 +1,6 @@
import { Global } from "../../../global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { AppCommand } from "./app"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
@@ -12,7 +11,6 @@ export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(AppCommand)
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
@@ -22,7 +20,7 @@ export const DebugCommand = cmd({
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},

View File

@@ -14,7 +14,7 @@ const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
})
@@ -25,7 +25,7 @@ export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
@@ -37,7 +37,7 @@ export const DocumentSymbolsCommand = cmd({
command: "document-symbols <uri>",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri)
console.log(JSON.stringify(results, null, 2))

View File

@@ -1,5 +1,5 @@
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -16,9 +16,8 @@ const TreeCommand = cmd({
type: "number",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
await bootstrap(process.cwd(), async () => {
console.log(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }))
})
},
})
@@ -40,10 +39,9 @@ const FilesCommand = cmd({
description: "Limit number of results",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
await bootstrap(process.cwd(), async () => {
const files = await Ripgrep.files({
cwd: app.path.cwd,
cwd: Instance.directory,
query: args.query,
glob: args.glob ? [args.glob] : undefined,
limit: args.limit,

View File

@@ -1,7 +1,14 @@
import { Project } from "../../../project/project"
import { Log } from "../../../util/log"
import { cmd } from "../cmd"
export const ScrapCommand = cmd({
command: "scrap",
builder: (yargs) => yargs,
async handler() {},
async handler() {
const timer = Log.Default.time("scrap")
const list = await Project.list()
console.log(list)
timer.stop()
},
})

View File

@@ -11,7 +11,7 @@ export const SnapshotCommand = cmd({
const TrackCommand = cmd({
command: "track",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.track())
})
},
@@ -26,7 +26,7 @@ const PatchCommand = cmd({
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.patch(args.hash))
})
},
@@ -41,7 +41,7 @@ const DiffCommand = cmd({
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
console.log(await Snapshot.diff(args.hash))
})
},

View File

@@ -0,0 +1,76 @@
import type { Argv } from "yargs"
import { Session } from "../../session"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
export const ExportCommand = cmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs: Argv) => {
return yargs.positional("sessionID", {
describe: "session id to export",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
let sessionID = args.sessionID
if (!sessionID) {
UI.empty()
prompts.intro("Export session")
const sessions = []
for await (const session of Session.list()) {
sessions.push(session)
}
if (sessions.length === 0) {
prompts.log.error("No sessions found")
prompts.outro("Done")
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = await prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
})),
})
if (prompts.isCancel(selectedSession)) {
throw new UI.CancelledError()
}
sessionID = selectedSession as string
prompts.outro("Exporting session...")
}
try {
const sessionInfo = await Session.get(sessionID!)
const messages = await Session.messages(sessionID!)
const exportData = {
info: sessionInfo,
messages: messages.map((msg) => ({
info: msg.info,
parts: msg.parts,
})),
}
console.log(JSON.stringify(exportData, null, 2))
} catch (error) {
UI.error(`Session not found: ${sessionID!}`)
process.exit(1)
}
})
},
})

View File

@@ -6,7 +6,7 @@ import { map, pipe, sortBy, values } from "remeda"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { App } from "../../app/app"
import { Instance } from "../../project/instance"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
@@ -21,7 +21,7 @@ export const GithubInstallCommand = cmd({
command: "install",
describe: "install the GitHub agent",
async handler() {
await App.provide({ cwd: process.cwd() }, async () => {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
@@ -63,8 +63,8 @@ export const GithubInstallCommand = cmd({
}
async function getAppInfo() {
const app = App.info()
if (!app.git) {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
@@ -88,7 +88,7 @@ export const GithubInstallCommand = cmd({
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: app.path.root }
return { owner, repo, root: Instance.worktree }
}
async function promptProvider() {

View File

@@ -1,4 +1,4 @@
import { App } from "../../app/app"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider/provider"
import { cmd } from "./cmd"
@@ -6,7 +6,7 @@ export const ModelsCommand = cmd({
command: "models",
describe: "list all available models",
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
await Instance.provide(process.cwd(), async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {

View File

@@ -74,7 +74,7 @@ export const RunCommand = cmd({
return
}
await bootstrap({ cwd: process.cwd() }, async () => {
await bootstrap(process.cwd(), async () => {
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
@@ -82,7 +82,6 @@ export const RunCommand = cmd({
return
}
}
const session = await (async () => {
if (args.continue) {
const it = Session.list()
@@ -198,11 +197,13 @@ export const RunCommand = cmd({
}
const messageID = Identifier.ascending("message")
const result = await Session.chat({
const result = await Session.prompt({
sessionID: session.id,
messageID,
providerID,
modelID,
model: {
providerID,
modelID,
},
agent: agent.name,
parts: [
{
@@ -215,7 +216,7 @@ export const RunCommand = cmd({
const isPiped = !process.stdout.isTTY
if (isPiped) {
const match = result.parts.findLast((x) => x.type === "text")
const match = result.parts.findLast((x: any) => x.type === "text") as any
if (match) process.stdout.write(UI.markdown(match.text))
if (errorMsg) process.stdout.write(errorMsg)
}

View File

@@ -1,6 +1,4 @@
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
@@ -21,26 +19,14 @@ export const ServeCommand = cmd({
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await bootstrap({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
server.stop()
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
server.stop()
},
})

View File

@@ -15,6 +15,7 @@ import { Ide } from "../../ide"
import { Flag } from "../../flag/flag"
import { Session } from "../../session"
import { Instance } from "../../project/instance"
declare global {
const OPENCODE_TUI_PATH: string
@@ -79,7 +80,7 @@ export const TuiCommand = cmd({
UI.error("Failed to change directory to " + cwd)
return
}
const result = await bootstrap({ cwd }, async (app) => {
const result = await bootstrap(cwd, async () => {
const sessionID = await (async () => {
if (args.continue) {
const it = Session.list()
@@ -146,7 +147,7 @@ export const TuiCommand = cmd({
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_APP_INFO: JSON.stringify(app),
OPENCODE_PROJECT: JSON.stringify(Instance.project),
},
onExit: () => {
server.stop()