diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts
index 43386dd6b2..e751b59faf 100644
--- a/packages/opencode/src/bus/global.ts
+++ b/packages/opencode/src/bus/global.ts
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
+ project?: string
+ workspace?: string
payload: any
},
]
diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts
index fe26a6672e..6db4eb04fc 100644
--- a/packages/opencode/src/bus/index.ts
+++ b/packages/opencode/src/bus/index.ts
@@ -1,9 +1,9 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Log } from "../util/log"
-import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -91,8 +91,13 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
+ const context = yield* InstanceState.context
+ const workspace = yield* InstanceState.workspaceID
+
GlobalBus.emit("event", {
directory: dir,
+ project: context.project.id,
+ workspace,
payload,
})
})
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4161c025c1..2f8d1f7bbb 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -14,7 +14,6 @@ import {
batch,
Show,
on,
- onCleanup,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
+import { ProjectProvider } from "@tui/context/project"
+import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
-import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
@@ -216,27 +216,29 @@ export function tui(input: {
headers={input.headers}
events={input.events}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise }) {
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
+ const event = useEvent()
const sdk = useSDK()
const toast = useToast()
const themeState = useTheme()
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise }) {
route,
routes,
bump: () => setRouteRev((x) => x + 1),
+ event,
sdk,
sync,
theme: themeState,
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise }) {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
- const workspaceID =
- route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
- workspaceID,
})
dialog.clear()
},
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise }) {
},
])
- sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
+ event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
- sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
+ event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise }) {
})
})
- sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
+ event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})
- sdk.event.on("session.deleted", (evt) => {
+ event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise }) {
}
})
- sdk.event.on("session.error", (evt) => {
+ event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error)
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise }) {
})
})
- sdk.event.on("installation.update-available", async (evt) => {
+ event.on("installation.update-available", async (evt) => {
const version = evt.properties.version
const skipped = kv.get("skipped_version")
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
index 84127b5763..037cebb729 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
@@ -1,5 +1,6 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
+import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType, sync: ReturnType
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
- directory: sync.data.path.directory || sdk.directory,
+ directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
export function DialogWorkspaceList() {
const dialog = useDialog()
+ const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
forceCreate,
})
- async function selectWorkspace(workspaceID: string) {
- if (workspaceID === "__local__") {
+ async function selectWorkspace(workspaceID: string | null) {
+ if (workspaceID == null) {
+ project.workspace.set(undefined)
if (localCount() > 0) {
dialog.replace(() => )
return
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
await open(workspaceID)
}
- const currentWorkspaceID = createMemo(() => {
- if (route.data.type === "session") {
- return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
- }
- return "__local__"
- })
+ const currentWorkspaceID = createMemo(() => project.workspace.current())
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
const options = createMemo(() => [
{
title: "Local",
- value: "__local__",
+ value: null,
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
- if (option.value === "__create__" || option.value === "__local__") return
+ if (option.value === "__create__" || option.value === null) return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
return
}
if (currentWorkspaceID() === option.value) {
+ project.workspace.set(undefined)
route.navigate({
type: "home",
})
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index 1c5ede4d72..2118fe98e1 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
- const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
+ const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 747c61fd0b..ba6df1d6bb 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
+import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0
+ const event = useEvent()
- sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
+ event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return
input.insertText(evt.properties.text)
setTimeout(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts
index 17e5c180a1..81f2173980 100644
--- a/packages/opencode/src/cli/cmd/tui/context/directory.ts
+++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts
@@ -1,11 +1,13 @@
import { createMemo } from "solid-js"
+import { useProject } from "./project"
import { useSync } from "./sync"
import { Global } from "@/global"
export function useDirectory() {
+ const project = useProject()
const sync = useSync()
return createMemo(() => {
- const directory = sync.data.path.directory || process.cwd()
+ const directory = project.instance.path().directory || process.cwd()
const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts
new file mode 100644
index 0000000000..da073f6e92
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/event.ts
@@ -0,0 +1,41 @@
+import type { Event } from "@opencode-ai/sdk/v2"
+import { useProject } from "./project"
+import { useSDK } from "./sdk"
+
+export function useEvent() {
+ const project = useProject()
+ const sdk = useSDK()
+
+ function subscribe(handler: (event: Event) => void) {
+ return sdk.event.on("event", (event) => {
+ // Special hack for truly global events
+ if (event.directory === "global") {
+ handler(event.payload)
+ }
+
+ if (project.workspace.current()) {
+ if (event.workspace === project.workspace.current()) {
+ handler(event.payload)
+ }
+
+ return
+ }
+
+ if (event.directory === project.instance.directory()) {
+ handler(event.payload)
+ }
+ })
+ }
+
+ function on(type: T, handler: (event: Extract) => void) {
+ return subscribe((event) => {
+ if (event.type !== type) return
+ handler(event as Extract)
+ })
+ }
+
+ return {
+ subscribe,
+ on,
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx
new file mode 100644
index 0000000000..522e724013
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx
@@ -0,0 +1,65 @@
+import { batch } from "solid-js"
+import type { Path } from "@opencode-ai/sdk"
+import { createStore, reconcile } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+
+export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
+ name: "Project",
+ init: () => {
+ const sdk = useSDK()
+ const [store, setStore] = createStore({
+ project: {
+ id: undefined as string | undefined,
+ },
+ instance: {
+ path: {
+ state: "",
+ config: "",
+ worktree: "",
+ directory: sdk.directory ?? "",
+ } satisfies Path,
+ },
+ workspace: undefined as string | undefined,
+ })
+
+ async function sync() {
+ const workspace = store.workspace
+ const [path, project] = await Promise.all([
+ sdk.client.path.get({ workspace }),
+ sdk.client.project.current({ workspace }),
+ ])
+
+ batch(() => {
+ setStore("instance", "path", reconcile(path.data!))
+ setStore("project", "id", project.data?.id)
+ })
+ }
+
+ return {
+ data: store,
+ project() {
+ return store.project.id
+ },
+ instance: {
+ path() {
+ return store.instance.path
+ },
+ directory() {
+ return store.instance.path.directory
+ },
+ },
+ workspace: {
+ current() {
+ return store.workspace
+ },
+ set(next?: string | null) {
+ const workspace = next ?? undefined
+ if (store.workspace === workspace) return
+ setStore("workspace", workspace)
+ },
+ },
+ sync,
+ }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
index 939c2d5dc8..e9f463a13f 100644
--- a/packages/opencode/src/cli/cmd/tui/context/route.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
- workspaceID?: string
}
export type SessionRoute = {
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 348c3ca1db..ad35aa45c2 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -1,10 +1,11 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
- subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
+ subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK()
const emitter = createGlobalEmitter<{
- [key in Event["type"]]: Extract
+ event: GlobalEvent
}>()
- let queue: Event[] = []
+ let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
- emitter.emit(event.type, event)
+ emitter.emit("event", event)
}
})
}
- const handleEvent = (event: Event) => {
+ const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => {
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
- const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
+ const events = await sdk.global.event({ signal: ctrl.signal })
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => {
if (props.events) {
- const unsub = await props.events.subscribe(props.directory, handleEvent)
+ const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
} else {
startSSE()
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 11336d5002..bbdc743285 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -17,18 +17,19 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
+ Workspace,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
+import { useProject } from "@tui/context/project"
+import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
-import { batch, onMount } from "solid-js"
+import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
-import type { Path } from "@opencode-ai/sdk"
-import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource
}
formatter: FormatterStatus[]
- vcs: VcsInfo | undefined
- path: Path
workspaceList: Workspace[]
+ vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
mcp_resource: {},
formatter: [],
- vcs: undefined,
- path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
+ vcs: undefined,
})
+ const event = useEvent()
+ const project = useProject()
const sdk = useSDK()
async function syncWorkspaces() {
+ const workspace = project.workspace.current()
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
+ if (!result.data.some((item) => item.id === workspace)) {
+ project.workspace.set(undefined)
+ }
}
- sdk.event.listen((e) => {
- const event = e.details
+ event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
bootstrap()
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "lsp.updated": {
- sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
+ const workspace = project.workspace.current()
+ sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break
}
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() {
console.log("bootstrapping")
+ const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
- .list({ start: start })
+ .list({ start: start, workspace })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
- const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
- const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
+ const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
+ const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
- .get({}, { throwOnError: true })
+ .get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
- const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
- const configPromise = sdk.client.config.get({}, { throwOnError: true })
+ const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
+ const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
+ const projectPromise = project.sync()
const blockingRequests: Promise[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
+ projectPromise,
...(args.continue ? [sessionListPromise] : []),
]
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
- sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
- sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
- sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
- sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
- sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
- sdk.client.session.status().then((x) => {
+ sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
+ sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
+ sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
+ sdk.client.experimental.resource
+ .list({ workspace })
+ .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
+ sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
+ sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!))
}),
- sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
- sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
- sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
+ sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
+ sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
syncWorkspaces(),
]).then(() => {
setStore("status", "complete")
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
}
- onMount(() => {
- bootstrap()
- })
-
const fullSyncedSessions = new Set()
+ createEffect(
+ on(
+ () => project.workspace.current(),
+ () => {
+ fullSyncedSessions.clear()
+ void bootstrap()
+ },
+ ),
+ )
+
const result = {
data: store,
set: setStore,
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
+ get path() {
+ return project.instance.path()
+ },
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
+ const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([
- sdk.client.session.get({ sessionID }, { throwOnError: true }),
- sdk.client.session.messages({ sessionID, limit: 100 }),
- sdk.client.session.todo({ sessionID }),
- sdk.client.session.diff({ sessionID }),
+ sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
+ sdk.client.session.messages({ sessionID, limit: 100, workspace }),
+ sdk.client.session.todo({ sessionID, workspace }),
+ sdk.client.session.diff({ sessionID, workspace }),
])
setStore(
produce((draft) => {
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
},
workspace: {
+ list() {
+ return store.workspaceList
+ },
get(workspaceID: string) {
- return store.workspaceList.find((workspace) => workspace.id === workspaceID)
+ return store.workspaceList.find((item) => item.id === workspaceID)
},
sync: syncWorkspaces,
},
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
index 3609f6cc1a..e43a9cc37c 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
+++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
@@ -1,6 +1,7 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
+import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
route: ReturnType
routes: RouteMap
bump: () => void
+ event: ReturnType
sdk: ReturnType
sync: ReturnType
theme: ReturnType
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] {
return sync.data.provider
},
get path() {
- return sync.data.path
+ return sync.path
},
get vcs() {
if (!sync.data.vcs) return
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() {
return input.sdk.client
},
- event: input.sdk.event,
+ event: input.event,
renderer: input.renderer,
slots: {
register() {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index 79b5c4d7ab..1cce7fb396 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo"
+import { useProject } from "../context/project"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
export function Home() {
const sync = useSync()
+ const project = useProject()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal()
@@ -63,11 +65,16 @@ export function Home() {
-
+
}
+ workspaceID={project.workspace.current()}
+ right={}
placeholders={placeholder}
/>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 32a9d13367..c6bc231fca 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -15,7 +15,9 @@ import {
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
+import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
+import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -116,6 +118,8 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
+ const event = useEvent()
+ const project = useProject()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
@@ -172,10 +176,16 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
+ const toast = useToast()
+ const sdk = useSDK()
createEffect(async () => {
- await sync.session
- .sync(route.sessionID)
+ await sdk.client.session
+ .get({ sessionID: route.sessionID }, { throwOnError: true })
+ .then((x) => {
+ project.workspace.set(x.data?.workspaceID)
+ })
+ .then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
@@ -189,13 +199,10 @@ export function Session() {
})
})
- const toast = useToast()
- const sdk = useSDK()
-
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
- sdk.event.on("message.part.updated", (evt) => {
+ event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
@@ -224,7 +231,7 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
- sdk.event.on("session.status", (evt) => {
+ event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps) {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
- const base = sync.data.path.directory
+ const base = sync.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index df5c416777..0534b147a5 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
-import type { Event } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
- subscribe: async (directory, handler) => {
- const id = await client.call("subscribe", { directory })
- const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
- if (e.id === id) {
- handler(e.event)
- }
+ subscribe: async (handler) => {
+ return client.on("global.event", (e) => {
+ handler(e)
})
-
- return () => {
- unsub()
- client.call("unsubscribe", { id })
- }
},
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 15e02b8634..a71b95ce4c 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
-import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
-import type { Event } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
-import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
-import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap"
await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
let server: Awaited> | undefined
-const eventStreams = new Map()
-
-function startEventStream(directory: string) {
- const id = crypto.randomUUID()
-
- const abort = new AbortController()
- const signal = abort.signal
-
- eventStreams.set(id, abort)
-
- async function run() {
- while (!signal.aborted) {
- const shouldReconnect = await Instance.provide({
- directory,
- init: InstanceBootstrap,
- fn: () =>
- new Promise((resolve) => {
- Rpc.emit("event", {
- type: "server.connected",
- properties: {},
- } satisfies Event)
-
- let settled = false
- const settle = (value: boolean) => {
- if (settled) return
- settled = true
- signal.removeEventListener("abort", onAbort)
- unsub()
- resolve(value)
- }
-
- const unsub = Bus.subscribeAll((event) => {
- Rpc.emit("event", {
- id,
- event: event as Event,
- })
- if (event.type === Bus.InstanceDisposed.type) {
- settle(true)
- }
- })
-
- const onAbort = () => {
- settle(false)
- }
-
- signal.addEventListener("abort", onAbort, { once: true })
- }),
- }).catch((error) => {
- Log.Default.error("event stream subscribe error", {
- error: error instanceof Error ? error.message : error,
- })
- return false
- })
-
- if (!shouldReconnect || signal.aborted) {
- break
- }
-
- if (!signal.aborted) {
- await sleep(250)
- }
- }
- }
-
- run().catch((error) => {
- Log.Default.error("event stream error", {
- error: error instanceof Error ? error.message : error,
- })
- })
-
- return id
-}
-
-function stopEventStream(id: string) {
- const abortController = eventStreams.get(id)
- if (!abortController) return
-
- abortController.abort()
- eventStreams.delete(id)
-}
-
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record; body?: string }) {
const headers = { ...input.headers }
@@ -167,19 +83,9 @@ export const rpc = {
async reload() {
await Config.invalidate(true)
},
- async subscribe(input: { directory: string | undefined }) {
- return startEventStream(input.directory || process.cwd())
- },
- async unsubscribe(input: { id: string }) {
- stopEventStream(input.id)
- },
async shutdown() {
Log.Default.info("worker shutting down")
- for (const id of [...eventStreams.keys()]) {
- stopEventStream(id)
- }
-
await Instance.disposeAll()
if (server) await server.stop(true)
},
diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts
new file mode 100644
index 0000000000..37204d17d6
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace-context.ts
@@ -0,0 +1,22 @@
+import { Context } from "../util/context"
+import type { WorkspaceID } from "../control-plane/schema"
+
+export interface WorkspaceContext {
+ workspaceID: string
+}
+
+const context = Context.create("instance")
+
+export const WorkspaceContext = {
+ async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise {
+ return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
+ },
+
+ get workspaceID() {
+ try {
+ return context.use().workspaceID
+ } catch (err) {
+ return undefined
+ }
+ },
+}
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index bb0fd60020..a030d0b6c8 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -134,12 +134,12 @@ export namespace Workspace {
continue
}
- await parseSSE(res.body, stop, (event) => {
- GlobalBus.emit("event", {
- directory: space.id,
- payload: event,
- })
- })
+ // await parseSSE(res.body, stop, (event) => {
+ // GlobalBus.emit("event", {
+ // directory: space.id,
+ // payload: event,
+ // })
+ // })
// Wait 250ms and retry if SSE connection fails
await sleep(250)
diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts
index d3939b2640..07a510a4f8 100644
--- a/packages/opencode/src/effect/instance-ref.ts
+++ b/packages/opencode/src/effect/instance-ref.ts
@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
+
+export const WorkspaceRef = ServiceMap.Reference("~opencode/WorkspaceRef", {
+ defaultValue: () => undefined,
+})
diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts
index a379d3afc0..878648855e 100644
--- a/packages/opencode/src/effect/instance-state.ts
+++ b/packages/opencode/src/effect/instance-state.ts
@@ -1,8 +1,9 @@
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context"
-import { InstanceRef } from "./instance-ref"
+import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState"
@@ -28,6 +29,10 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current
})
+ export const workspaceID = Effect.gen(function* () {
+ return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
+ })
+
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = (
diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts
index f609986b58..c16adec627 100644
--- a/packages/opencode/src/effect/run-service.ts
+++ b/packages/opencode/src/effect/run-service.ts
@@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance"
import { Context } from "@/util/context"
-import { InstanceRef } from "./instance-ref"
+import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe()
function attach(effect: Effect.Effect): Effect.Effect {
try {
const ctx = Instance.current
- return Effect.provideService(effect, InstanceRef, ctx)
+ const workspaceID = WorkspaceContext.workspaceID
+ return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index a0d6f2414a..33078183b9 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state"
export interface InstanceContext {
@@ -20,19 +21,9 @@ const disposal = {
all: undefined as Promise | undefined,
}
-function emit(directory: string) {
- GlobalBus.emit("event", {
- directory,
- payload: {
- type: "server.instance.disposed",
- properties: {
- directory,
- },
- },
- })
-}
+function emitDisposed(directory: string) {}
-function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) {
+function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
@@ -93,6 +84,7 @@ export const Instance = {
get project() {
return context.use().project
},
+
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
@@ -131,15 +123,39 @@ export const Instance = {
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
- emit(directory)
+
+ GlobalBus.emit("event", {
+ directory,
+ project: input.project?.id,
+ workspace: WorkspaceContext.workspaceID,
+ payload: {
+ type: "server.instance.disposed",
+ properties: {
+ directory,
+ },
+ },
+ })
+
return await next
},
async dispose() {
const directory = Instance.directory
+ const project = Instance.project
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
- emit(directory)
+
+ GlobalBus.emit("event", {
+ directory,
+ project: project.id,
+ workspace: WorkspaceContext.workspaceID,
+ payload: {
+ type: "server.instance.disposed",
+ properties: {
+ directory,
+ },
+ },
+ })
},
async disposeAll() {
if (disposal.all) return disposal.all
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f587f50b39..8db8df5d55 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -137,6 +137,8 @@ export namespace Project {
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
+ directory: "global",
+ project: data.id,
payload: { type: Event.Updated.type, properties: data },
}),
)
diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts
index 55853d974d..dcc7924c68 100644
--- a/packages/opencode/src/server/router.ts
+++ b/packages/opencode/src/server/router.ts
@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
+import { Session } from "@/session"
+import { SessionID } from "@/session/schema"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -26,6 +29,16 @@ function local(method: string, path: string) {
return false
}
+async function getSessionWorkspace(url: URL) {
+ if (url.pathname === "/session/status") return null
+
+ const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
+ if (!id) return null
+
+ const session = await Session.get(SessionID.make(id)).catch(() => undefined)
+ return session?.workspaceID
+}
+
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade))
@@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
)
const url = new URL(c.req.url)
- const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
- // TODO: If session is being routed, force it to lookup the
- // project/workspace
+ const sessionWorkspaceID = await getSessionWorkspace(url)
+ const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
- // If no workspace is provided we use the "project" workspace
- if (!workspaceParam) {
+ // If no workspace is provided we use the project
+ if (!workspaceID) {
return Instance.provide({
directory,
init: InstanceBootstrap,
@@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
})
}
- const workspaceID = WorkspaceID.make(workspaceParam)
- const workspace = await Workspace.get(workspaceID)
+ const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
@@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const target = await adaptor.target(workspace)
if (target.type === "local") {
- return Instance.provide({
- directory: target.directory,
- init: InstanceBootstrap,
- async fn() {
- return routes().fetch(c.req.raw, c.env)
- },
+ return WorkspaceContext.provide({
+ workspaceID: WorkspaceID.make(workspaceID),
+ fn: () =>
+ Instance.provide({
+ directory: target.directory,
+ init: InstanceBootstrap,
+ async fn() {
+ return routes().fetch(c.req.raw, c.env)
+ },
+ }),
})
}
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 16b9e559f2..ec7afcf23d 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
z
.object({
directory: z.string(),
+ project: z.string().optional(),
+ workspace: z.string().optional(),
payload: BusEvent.payloads(),
})
.meta({
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 54986d65cd..e0e7dab4c1 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -246,6 +246,7 @@ export namespace Worktree {
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
const ctx = yield* InstanceState.context
+ const workspaceID = yield* InstanceState.workspaceID
const projectID = ctx.project.id
const extra = startCommand?.trim()
@@ -255,6 +256,8 @@ export namespace Worktree {
log.error("worktree checkout failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
+ project: ctx.project.id,
+ workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } },
})
return
@@ -272,6 +275,8 @@ export namespace Worktree {
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
+ project: ctx.project.id,
+ workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } },
})
return false
@@ -281,6 +286,8 @@ export namespace Worktree {
GlobalBus.emit("event", {
directory: info.directory,
+ project: ctx.project.id,
+ workspace: workspaceID,
payload: {
type: Event.Ready.type,
properties: { name: info.name, branch: info.branch },
diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx
new file mode 100644
index 0000000000..ec686b3688
--- /dev/null
+++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx
@@ -0,0 +1,293 @@
+/** @jsxImportSource @opentui/solid */
+import { afterEach, describe, expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import { onMount } from "solid-js"
+import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
+import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
+import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
+import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
+import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
+
+const sighup = new Set(process.listeners("SIGHUP"))
+
+afterEach(() => {
+ for (const fn of process.listeners("SIGHUP")) {
+ if (!sighup.has(fn)) process.off("SIGHUP", fn)
+ }
+})
+
+function json(data: unknown) {
+ return new Response(JSON.stringify(data), {
+ headers: {
+ "content-type": "application/json",
+ },
+ })
+}
+
+async function wait(fn: () => boolean, timeout = 2000) {
+ const start = Date.now()
+ while (!fn()) {
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+ await Bun.sleep(10)
+ }
+}
+
+function data(workspace?: string | null) {
+ const tag = workspace ?? "root"
+ return {
+ session: {
+ id: "ses_1",
+ title: `session-${tag}`,
+ workspaceID: workspace ?? undefined,
+ time: {
+ updated: 1,
+ },
+ },
+ message: {
+ info: {
+ id: "msg_1",
+ sessionID: "ses_1",
+ role: "assistant",
+ time: {
+ created: 1,
+ completed: 1,
+ },
+ },
+ parts: [
+ {
+ id: "part_1",
+ messageID: "msg_1",
+ sessionID: "ses_1",
+ type: "text",
+ text: `part-${tag}`,
+ },
+ ],
+ },
+ todo: [
+ {
+ id: `todo-${tag}`,
+ content: `todo-${tag}`,
+ status: "pending",
+ priority: "medium",
+ },
+ ],
+ diff: [
+ {
+ file: `${tag}.ts`,
+ patch: "",
+ additions: 0,
+ deletions: 0,
+ },
+ ],
+ }
+}
+
+type Hit = {
+ path: string
+ workspace?: string
+}
+
+function createFetch(log: Hit[]) {
+ return Object.assign(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const req = new Request(input, init)
+ const url = new URL(req.url)
+ const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
+ log.push({
+ path: url.pathname,
+ workspace,
+ })
+
+ if (url.pathname === "/config/providers") {
+ return json({ providers: [], default: {} })
+ }
+ if (url.pathname === "/provider") {
+ return json({ all: [], default: {}, connected: [] })
+ }
+ if (url.pathname === "/experimental/console") {
+ return json({})
+ }
+ if (url.pathname === "/agent") {
+ return json([])
+ }
+ if (url.pathname === "/config") {
+ return json({})
+ }
+ if (url.pathname === "/project/current") {
+ return json({ id: `proj-${workspace ?? "root"}` })
+ }
+ if (url.pathname === "/path") {
+ return json({
+ state: `/tmp/${workspace ?? "root"}/state`,
+ config: `/tmp/${workspace ?? "root"}/config`,
+ worktree: "/tmp/worktree",
+ directory: `/tmp/${workspace ?? "root"}`,
+ })
+ }
+ if (url.pathname === "/session") {
+ return json([])
+ }
+ if (url.pathname === "/command") {
+ return json([])
+ }
+ if (url.pathname === "/lsp") {
+ return json([])
+ }
+ if (url.pathname === "/mcp") {
+ return json({})
+ }
+ if (url.pathname === "/experimental/resource") {
+ return json({})
+ }
+ if (url.pathname === "/formatter") {
+ return json([])
+ }
+ if (url.pathname === "/session/status") {
+ return json({})
+ }
+ if (url.pathname === "/provider/auth") {
+ return json({})
+ }
+ if (url.pathname === "/vcs") {
+ return json({ branch: "main" })
+ }
+ if (url.pathname === "/experimental/workspace") {
+ return json([{ id: "ws_a" }, { id: "ws_b" }])
+ }
+ if (url.pathname === "/session/ses_1") {
+ return json(data(workspace).session)
+ }
+ if (url.pathname === "/session/ses_1/message") {
+ return json([data(workspace).message])
+ }
+ if (url.pathname === "/session/ses_1/todo") {
+ return json(data(workspace).todo)
+ }
+ if (url.pathname === "/session/ses_1/diff") {
+ return json(data(workspace).diff)
+ }
+
+ throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
+ },
+ { preconnect: fetch.preconnect.bind(fetch) },
+ ) satisfies typeof fetch
+}
+
+async function mount(log: Hit[]) {
+ let project!: ReturnType
+ let sync!: ReturnType
+ let done!: () => void
+ const ready = new Promise((resolve) => {
+ done = resolve
+ })
+
+ const app = await testRender(() => (
+ () => {} }}
+ >
+
+
+
+
+ {
+ project = ctx.project
+ sync = ctx.sync
+ done()
+ }}
+ />
+
+
+
+
+
+ ))
+
+ await ready
+ return { app, project, sync }
+}
+
+async function waitBoot(log: Hit[], workspace?: string) {
+ await wait(() => log.some((item) => item.path === "/experimental/workspace"))
+ if (!workspace) return
+ await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
+}
+
+function Probe(props: {
+ onReady: (ctx: { project: ReturnType; sync: ReturnType }) => void
+}) {
+ const project = useProject()
+ const sync = useSync()
+
+ onMount(() => {
+ props.onReady({ project, sync })
+ })
+
+ return
+}
+
+describe("SyncProvider", () => {
+ test("re-runs bootstrap requests when the active workspace changes", async () => {
+ const log: Hit[] = []
+ const { app, project } = await mount(log)
+
+ try {
+ await waitBoot(log)
+ log.length = 0
+
+ project.workspace.set("ws_a")
+
+ await waitBoot(log, "ws_a")
+
+ expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
+ expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
+ expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
+ expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+
+ test("clears full-sync cache when the active workspace changes", async () => {
+ const log: Hit[] = []
+ const { app, project, sync } = await mount(log)
+
+ try {
+ await waitBoot(log)
+
+ log.length = 0
+ project.workspace.set("ws_a")
+ await waitBoot(log, "ws_a")
+ expect(project.workspace.current()).toBe("ws_a")
+
+ log.length = 0
+ await sync.session.sync("ses_1")
+
+ expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
+ expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
+ expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
+ expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
+ expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
+
+ log.length = 0
+ project.workspace.set("ws_b")
+ await waitBoot(log, "ws_b")
+ expect(project.workspace.current()).toBe("ws_b")
+
+ log.length = 0
+ await sync.session.sync("ses_1")
+ await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
+
+ expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
+ expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
+ expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
+ expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
+ expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+})
diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx
new file mode 100644
index 0000000000..5b0fcad3c9
--- /dev/null
+++ b/packages/opencode/test/cli/tui/use-event.test.tsx
@@ -0,0 +1,175 @@
+/** @jsxImportSource @opentui/solid */
+import { describe, expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
+import { onMount } from "solid-js"
+import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
+import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
+import { useEvent } from "../../../src/cli/cmd/tui/context/event"
+
+async function wait(fn: () => boolean, timeout = 2000) {
+ const start = Date.now()
+ while (!fn()) {
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+ await Bun.sleep(10)
+ }
+}
+
+function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
+ return {
+ directory: input.directory,
+ workspace: input.workspace,
+ payload,
+ }
+}
+
+function vcs(branch: string): Event {
+ return {
+ type: "vcs.branch.updated",
+ properties: {
+ branch,
+ },
+ }
+}
+
+function update(version: string): Event {
+ return {
+ type: "installation.update-available",
+ properties: {
+ version,
+ },
+ }
+}
+
+function createSource() {
+ let fn: ((event: GlobalEvent) => void) | undefined
+
+ return {
+ source: {
+ subscribe: async (handler: (event: GlobalEvent) => void) => {
+ fn = handler
+ return () => {
+ if (fn === handler) fn = undefined
+ }
+ },
+ },
+ emit(evt: GlobalEvent) {
+ if (!fn) throw new Error("event source not ready")
+ fn(evt)
+ },
+ }
+}
+
+async function mount() {
+ const source = createSource()
+ const seen: Event[] = []
+ let project!: ReturnType
+ let done!: () => void
+ const ready = new Promise((resolve) => {
+ done = resolve
+ })
+
+ const app = await testRender(() => (
+
+
+ {
+ project = ctx.project
+ done()
+ }}
+ seen={seen}
+ />
+
+
+ ))
+
+ await ready
+ return { app, emit: source.emit, project, seen }
+}
+
+function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) {
+ const project = useProject()
+ const event = useEvent()
+
+ onMount(() => {
+ event.subscribe((evt) => {
+ props.seen.push(evt)
+ })
+ props.onReady({ project })
+ })
+
+ return
+}
+
+describe("useEvent", () => {
+ test("delivers matching directory events without an active workspace", async () => {
+ const { app, emit, seen } = await mount()
+
+ try {
+ emit(event(vcs("main"), { directory: "/tmp/root" }))
+
+ await wait(() => seen.length === 1)
+
+ expect(seen).toEqual([vcs("main")])
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+
+ test("ignores non-matching directory events without an active workspace", async () => {
+ const { app, emit, seen } = await mount()
+
+ try {
+ emit(event(vcs("other"), { directory: "/tmp/other" }))
+ await Bun.sleep(30)
+
+ expect(seen).toHaveLength(0)
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+
+ test("delivers matching workspace events when a workspace is active", async () => {
+ const { app, emit, project, seen } = await mount()
+
+ try {
+ project.workspace.set("ws_a")
+ emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
+
+ await wait(() => seen.length === 1)
+
+ expect(seen).toEqual([vcs("ws")])
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+
+ test("ignores non-matching workspace events when a workspace is active", async () => {
+ const { app, emit, project, seen } = await mount()
+
+ try {
+ project.workspace.set("ws_a")
+ emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
+ await Bun.sleep(30)
+
+ expect(seen).toHaveLength(0)
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+
+ test("delivers truly global events even when a workspace is active", async () => {
+ const { app, emit, project, seen } = await mount()
+
+ try {
+ project.workspace.set("ws_a")
+ emit(event(update("1.2.3"), { directory: "global" }))
+
+ await wait(() => seen.length === 1)
+
+ expect(seen).toEqual([update("1.2.3")])
+ } finally {
+ app.renderer.destroy()
+ }
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 62c62e138f..823c452f9d 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1011,6 +1011,8 @@ export type Event =
export type GlobalEvent = {
directory: string
+ project?: string
+ workspace?: string
payload: Event
}
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 450de51319..40361c280d 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -9926,6 +9926,12 @@
"directory": {
"type": "string"
},
+ "project": {
+ "type": "string"
+ },
+ "workspace": {
+ "type": "string"
+ },
"payload": {
"$ref": "#/components/schemas/Event"
}