Sfoglia il codice sorgente

refactor(tui): switch to global events and start passing workspace param (#21719)

James Long 5 mesi fa
parent
commit
42206da1f8
31 ha cambiato i file con 850 aggiunte e 246 eliminazioni
  1. 2 0
      packages/opencode/src/bus/global.ts
  2. 6 1
      packages/opencode/src/bus/index.ts
  3. 33 32
      packages/opencode/src/cli/cmd/tui/app.tsx
  4. 10 11
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
  5. 1 1
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  6. 3 1
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  7. 3 1
      packages/opencode/src/cli/cmd/tui/context/directory.ts
  8. 41 0
      packages/opencode/src/cli/cmd/tui/context/event.ts
  9. 65 0
      packages/opencode/src/cli/cmd/tui/context/project.tsx
  10. 0 1
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  11. 9 8
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  12. 56 34
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  13. 4 2
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  14. 10 3
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  15. 15 8
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  16. 4 12
      packages/opencode/src/cli/cmd/tui/thread.ts
  17. 1 95
      packages/opencode/src/cli/cmd/tui/worker.ts
  18. 22 0
      packages/opencode/src/control-plane/workspace-context.ts
  19. 6 6
      packages/opencode/src/control-plane/workspace.ts
  20. 4 0
      packages/opencode/src/effect/instance-ref.ts
  21. 6 1
      packages/opencode/src/effect/instance-state.ts
  22. 4 2
      packages/opencode/src/effect/run-service.ts
  23. 30 14
      packages/opencode/src/project/instance.ts
  24. 2 0
      packages/opencode/src/project/project.ts
  25. 28 13
      packages/opencode/src/server/router.ts
  26. 2 0
      packages/opencode/src/server/routes/global.ts
  27. 7 0
      packages/opencode/src/worktree/index.ts
  28. 293 0
      packages/opencode/test/cli/tui/sync-provider.test.tsx
  29. 175 0
      packages/opencode/test/cli/tui/use-event.test.tsx
  30. 2 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  31. 6 0
      packages/sdk/openapi.json

+ 2 - 0
packages/opencode/src/bus/global.ts

@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
   event: [
     {
       directory?: string
+      project?: string
+      workspace?: string
       payload: any
     },
   ]

+ 6 - 1
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,
           })
         })

+ 33 - 32
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}
                       >
-                        <SyncProvider>
-                          <ThemeProvider mode={mode}>
-                            <LocalProvider>
-                              <KeybindProvider>
-                                <PromptStashProvider>
-                                  <DialogProvider>
-                                    <CommandProvider>
-                                      <FrecencyProvider>
-                                        <PromptHistoryProvider>
-                                          <PromptRefProvider>
-                                            <App onSnapshot={input.onSnapshot} />
-                                          </PromptRefProvider>
-                                        </PromptHistoryProvider>
-                                      </FrecencyProvider>
-                                    </CommandProvider>
-                                  </DialogProvider>
-                                </PromptStashProvider>
-                              </KeybindProvider>
-                            </LocalProvider>
-                          </ThemeProvider>
-                        </SyncProvider>
+                        <ProjectProvider>
+                          <SyncProvider>
+                            <ThemeProvider mode={mode}>
+                              <LocalProvider>
+                                <KeybindProvider>
+                                  <PromptStashProvider>
+                                    <DialogProvider>
+                                      <CommandProvider>
+                                        <FrecencyProvider>
+                                          <PromptHistoryProvider>
+                                            <PromptRefProvider>
+                                              <App onSnapshot={input.onSnapshot} />
+                                            </PromptRefProvider>
+                                          </PromptHistoryProvider>
+                                        </FrecencyProvider>
+                                      </CommandProvider>
+                                    </DialogProvider>
+                                  </PromptStashProvider>
+                                </KeybindProvider>
+                              </LocalProvider>
+                            </ThemeProvider>
+                          </SyncProvider>
+                        </ProjectProvider>
                       </SDKProvider>
                     </TuiConfigProvider>
                   </RouteProvider>
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   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<string[]> }) {
     route,
     routes,
     bump: () => setRouteRev((x) => x + 1),
+    event,
     sdk,
     sync,
     theme: themeState,
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         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<string[]> }) {
     },
   ])
 
-  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<string[]> }) {
     })
   })
 
-  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<string[]> }) {
     }
   })
 
-  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<string[]> }) {
     })
   })
 
-  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")

+ 10 - 11
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<typeof useSDK>, sync: ReturnType<typeof useSync>
   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(() => <DialogSessionList localOnly={true} />)
         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",
               })

+ 1 - 1
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

+ 3 - 1
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(() => {

+ 3 - 1
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

+ 41 - 0
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<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
+    return subscribe((event) => {
+      if (event.type !== type) return
+      handler(event as Extract<Event, { type: T }>)
+    })
+  }
+
+  return {
+    subscribe,
+    on,
+  }
+}

+ 65 - 0
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,
+    }
+  },
+})

+ 0 - 1
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 = {

+ 9 - 8
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, { type: key }>
+      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()

+ 56 - 34
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<unknown>[] = [
         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<string>()
+    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,
       },

+ 4 - 2
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<typeof useRoute>
   routes: RouteMap
   bump: () => void
+  event: ReturnType<typeof useEvent>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
   theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): 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() {

+ 10 - 3
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<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
         </box>
         <box height={1} minHeight={0} flexShrink={1} />
         <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
-          <TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
+          <TuiPluginRuntime.Slot
+            name="home_prompt"
+            mode="replace"
+            workspace_id={project.workspace.current()}
+            ref={bind}
+          >
             <Prompt
               ref={bind}
-              workspaceID={route.workspaceID}
-              right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
+              workspaceID={project.workspace.current()}
+              right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
               placeholders={placeholder}
             />
           </TuiPluginRuntime.Slot>

+ 15 - 8
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<typeof BashTool>) {
     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)

+ 4 - 12
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<GlobalEvent>("global.event", (e) => {
+        handler(e)
       })
-
-      return () => {
-        unsub()
-        client.call("unsubscribe", { id })
-      }
     },
   }
 }

+ 1 - 95
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<ReturnType<typeof Server.listen>> | undefined
 
-const eventStreams = new Map<string, AbortController>()
-
-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<boolean>((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<string, string>; 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)
   },

+ 22 - 0
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<WorkspaceContext>("instance")
+
+export const WorkspaceContext = {
+  async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
+    return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
+  },
+
+  get workspaceID() {
+    try {
+      return context.use().workspaceID
+    } catch (err) {
+      return undefined
+    }
+  },
+}

+ 6 - 6
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)

+ 4 - 0
packages/opencode/src/effect/instance-ref.ts

@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
 export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
   defaultValue: () => undefined,
 })
+
+export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
+  defaultValue: () => undefined,
+})

+ 6 - 1
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 = <A, E = never, R = never>(

+ 4 - 2
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<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
   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
   }

+ 30 - 14
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<void> | 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<any>; project?: Project.Info; worktree?: string }) {
+function boot(input: { directory: string; init?: () => Promise<any>; 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

+ 2 - 0
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 },
           }),
         )

+ 28 - 13
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)
+            },
+          }),
       })
     }
 

+ 2 - 0
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({

+ 7 - 0
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 },

+ 293 - 0
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<typeof useProject>
+  let sync!: ReturnType<typeof useSync>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const app = await testRender(() => (
+    <SDKProvider
+      url="http://test"
+      directory="/tmp/root"
+      fetch={createFetch(log)}
+      events={{ subscribe: async () => () => {} }}
+    >
+      <ArgsProvider continue={false}>
+        <ExitProvider>
+          <ProjectProvider>
+            <SyncProvider>
+              <Probe
+                onReady={(ctx) => {
+                  project = ctx.project
+                  sync = ctx.sync
+                  done()
+                }}
+              />
+            </SyncProvider>
+          </ProjectProvider>
+        </ExitProvider>
+      </ArgsProvider>
+    </SDKProvider>
+  ))
+
+  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<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
+}) {
+  const project = useProject()
+  const sync = useSync()
+
+  onMount(() => {
+    props.onReady({ project, sync })
+  })
+
+  return <box />
+}
+
+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()
+    }
+  })
+})

+ 175 - 0
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<typeof useProject>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const app = await testRender(() => (
+    <SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
+      <ProjectProvider>
+        <Probe
+          onReady={(ctx) => {
+            project = ctx.project
+            done()
+          }}
+          seen={seen}
+        />
+      </ProjectProvider>
+    </SDKProvider>
+  ))
+
+  await ready
+  return { app, emit: source.emit, project, seen }
+}
+
+function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
+  const project = useProject()
+  const event = useEvent()
+
+  onMount(() => {
+    event.subscribe((evt) => {
+      props.seen.push(evt)
+    })
+    props.onReady({ project })
+  })
+
+  return <box />
+}
+
+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()
+    }
+  })
+})

+ 2 - 0
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
 }
 

+ 6 - 0
packages/sdk/openapi.json

@@ -9926,6 +9926,12 @@
           "directory": {
             "type": "string"
           },
+          "project": {
+            "type": "string"
+          },
+          "workspace": {
+            "type": "string"
+          },
           "payload": {
             "$ref": "#/components/schemas/Event"
           }