Răsfoiți Sursa

feat(app): scope sdk/sync hooks per-route so /new-session targets its draft server (#32290)

Brendan Allan 2 luni în urmă
părinte
comite
010b456dfd
53 a modificat fișierele cu 524 adăugiri și 458 ștergeri
  1. 91 48
      packages/app/src/app.tsx
  2. 11 11
      packages/app/src/components/dialog-connect-provider.tsx
  3. 5 5
      packages/app/src/components/dialog-custom-provider.tsx
  4. 6 6
      packages/app/src/components/dialog-fork.tsx
  5. 5 5
      packages/app/src/components/dialog-select-file.tsx
  6. 2 2
      packages/app/src/components/dialog-select-mcp.tsx
  7. 14 14
      packages/app/src/components/prompt-input.tsx
  8. 3 3
      packages/app/src/components/prompt-input/submit.test.ts
  9. 25 25
      packages/app/src/components/prompt-input/submit.ts
  10. 1 1
      packages/app/src/components/session-context-usage.tsx
  11. 4 4
      packages/app/src/components/session/session-context-tab.tsx
  12. 1 1
      packages/app/src/components/session/session-header.tsx
  13. 6 6
      packages/app/src/components/session/session-new-view.tsx
  14. 4 4
      packages/app/src/components/settings-general.tsx
  15. 8 8
      packages/app/src/components/settings-providers.tsx
  16. 1 1
      packages/app/src/components/settings-server-picker.tsx
  17. 4 4
      packages/app/src/components/settings-v2/general.tsx
  18. 8 8
      packages/app/src/components/settings-v2/providers.tsx
  19. 4 4
      packages/app/src/components/status-popover-body.tsx
  20. 4 4
      packages/app/src/components/status-popover.tsx
  21. 3 3
      packages/app/src/components/terminal.tsx
  22. 2 2
      packages/app/src/components/titlebar.tsx
  23. 1 1
      packages/app/src/context/comments.tsx
  24. 2 2
      packages/app/src/context/directory-sync.ts
  25. 7 7
      packages/app/src/context/file.tsx
  26. 11 11
      packages/app/src/context/layout.tsx
  27. 11 10
      packages/app/src/context/local.tsx
  28. 1 1
      packages/app/src/context/mcp.ts
  29. 4 4
      packages/app/src/context/notification.tsx
  30. 10 10
      packages/app/src/context/permission.tsx
  31. 1 1
      packages/app/src/context/prompt.tsx
  32. 10 4
      packages/app/src/context/sdk.tsx
  33. 21 11
      packages/app/src/context/server-sdk.tsx
  34. 18 15
      packages/app/src/context/server-sync.tsx
  35. 4 1
      packages/app/src/context/sync.tsx
  36. 5 5
      packages/app/src/context/terminal.tsx
  37. 2 2
      packages/app/src/hooks/use-providers.ts
  38. 3 3
      packages/app/src/pages/directory-layout.tsx
  39. 7 7
      packages/app/src/pages/home.tsx
  40. 52 52
      packages/app/src/pages/layout.tsx
  41. 2 2
      packages/app/src/pages/layout/project-avatar-state.ts
  42. 2 2
      packages/app/src/pages/layout/sidebar-items.tsx
  43. 4 4
      packages/app/src/pages/layout/sidebar-project.tsx
  44. 8 8
      packages/app/src/pages/layout/sidebar-workspace.tsx
  45. 2 2
      packages/app/src/pages/new-session.tsx
  46. 63 63
      packages/app/src/pages/session.tsx
  47. 1 1
      packages/app/src/pages/session/composer/session-composer-region.tsx
  48. 8 8
      packages/app/src/pages/session/composer/session-composer-state.ts
  49. 3 3
      packages/app/src/pages/session/composer/session-question-dock.tsx
  50. 28 28
      packages/app/src/pages/session/message-timeline.tsx
  51. 1 1
      packages/app/src/pages/session/review-tab.tsx
  52. 1 1
      packages/app/src/pages/session/usage-exceeded-dialogs.tsx
  53. 19 19
      packages/app/src/pages/session/use-session-commands.tsx

+ 91 - 48
packages/app/src/app.tsx

@@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
     createEffect(() => {
       if (!settings.general.newLayoutDesigns()) return
       if (params.id || search.draftId) return
-      if (!tabs.ready() || !sdk.directory) return
-      tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
+      if (!tabs.ready() || !sdk().directory) return
+      tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
     })
 
     return (
@@ -82,6 +82,45 @@ const SessionRoute = Object.assign(
   { preload: Session.preload },
 )
 
+// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
+// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
+// Notification/Models + the visual Layout) for that server.
+function SelectedServerLayout(props: ParentProps) {
+  return (
+    <ServerKey>
+      <ServerSDKProvider>
+        <ServerSyncProvider>
+          <ServerScopedShell>{props.children}</ServerScopedShell>
+        </ServerSyncProvider>
+      </ServerSDKProvider>
+    </ServerKey>
+  )
+}
+
+// Wraps /new-session. It resolves the draft's target server and provides the
+// server-scoped shell for that server — without ServerKey, so the page never depends
+// on the globally "selected" server.
+function DraftServerLayout(props: ParentProps) {
+  const server = useServer()
+  const tabs = useTabs()
+  const [search] = useSearchParams<{ draftId?: string }>()
+  const conn = createMemo(() => {
+    const id = search.draftId
+    if (!id) return undefined
+    const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
+    if (!draft) return undefined
+    return server.list.find((c) => ServerConnection.key(c) === draft.server)
+  })
+
+  return (
+    <ServerSDKProvider server={conn}>
+      <ServerSyncProvider server={conn}>
+        <ServerScopedShell>{props.children}</ServerScopedShell>
+      </ServerSyncProvider>
+    </ServerSDKProvider>
+  )
+}
+
 function DraftRoute() {
   const [search] = useSearchParams<{ draftId?: string }>()
   const tabs = useTabs()
@@ -95,19 +134,15 @@ function DraftRoute() {
 }
 
 function ResolvedDraftRoute(props: { draftID: string }) {
-  const server = useServer()
   const tabs = useTabs()
   const draft = createMemo(() =>
     tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
   )
 
-  createEffect(() => {
-    const current = draft()
-    if (current && current.server !== server.key) server.setActive(current.server)
-  })
-
   // Key on the directory so retargeting the draft's project re-instantiates the
-  // SDK/data providers for the new directory while keeping the same draft id.
+  // directory-scoped providers while keeping the same draft id. The draft's target
+  // server is provided by DraftServerLayout, so changing only the server updates the
+  // SDK/sync hooks without remounting the composer.
   const directory = () => draft()?.directory
 
   return (
@@ -171,27 +206,36 @@ function BodyDesignClass() {
   return null
 }
 
-function AppShellProviders(props: ParentProps) {
+// Server-agnostic providers shared across every route. These live in the shared
+// shell (router root) so they stay mounted regardless of the active server/route.
+function SharedProviders(props: ParentProps) {
   return (
     <SettingsProvider>
       <BodyDesignClass />
-      <PermissionProvider>
-        <LayoutProvider>
-          <NotificationProvider>
-            <ModelsProvider>
-              <CommandProvider>
-                <HighlightsProvider>
-                  <Layout>{props.children}</Layout>
-                </HighlightsProvider>
-              </CommandProvider>
-            </ModelsProvider>
-          </NotificationProvider>
-        </LayoutProvider>
-      </PermissionProvider>
+      <CommandProvider>
+        <HighlightsProvider>{props.children}</HighlightsProvider>
+      </CommandProvider>
     </SettingsProvider>
   )
 }
 
+// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
+// each per-route server layout so they resolve to that route's server (selected vs
+// draft). The Layout remounts when crossing between those groups.
+function ServerScopedShell(props: ParentProps) {
+  return (
+    <PermissionProvider>
+      <LayoutProvider>
+        <NotificationProvider>
+          <ModelsProvider>
+            <Layout>{props.children}</Layout>
+          </ModelsProvider>
+        </NotificationProvider>
+      </LayoutProvider>
+    </PermissionProvider>
+  )
+}
+
 function SessionProviders(props: ParentProps) {
   return (
     <TerminalProvider>
@@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) {
   )
 }
 
-function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
-  return (
-    <AppShellProviders>
-      {/*<Suspense fallback={<Loading />}>*/}
-      {props.appChildren}
-      {props.children}
-      {/*</Suspense>*/}
-    </AppShellProviders>
-  )
-}
-
 export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
   return (
     <MetaProvider>
@@ -385,6 +418,20 @@ export function AppInterface(props: {
   router?: Component<BaseRouterProps>
   disableHealthCheck?: boolean
 }) {
+  // The shared shell holds only server-agnostic providers (QueryClient + Settings/
+  // Command/Highlights) and stays mounted across every route. The server-scoped
+  // providers and the visual Layout live in the per-route layouts below, so they
+  // resolve to that route's server (selected for most routes, the draft's server for
+  // /new-session). appChildren is server-agnostic, so it renders here once.
+  const ServerShell = (shellProps: ParentProps) => (
+    <QueryProvider>
+      <SharedProviders>
+        {props.children}
+        {shellProps.children}
+      </SharedProviders>
+    </QueryProvider>
+  )
+
   return (
     <ServerProvider
       defaultServer={props.defaultServer}
@@ -397,23 +444,19 @@ export function AppInterface(props: {
             component={props.router ?? Router}
             root={(routerProps) => (
               <TabsProvider>
-                <ServerKey>
-                  <QueryProvider>
-                    <ServerSDKProvider>
-                      <ServerSyncProvider>
-                        <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
-                      </ServerSyncProvider>
-                    </ServerSDKProvider>
-                  </QueryProvider>
-                </ServerKey>
+                <ServerShell>{routerProps.children}</ServerShell>
               </TabsProvider>
             )}
           >
-            <Route path="/" component={HomeRoute} />
-            <Route path="/new-session" component={DraftRoute} />
-            <Route path="/:dir" component={DirectoryLayout}>
-              <Route path="/" component={() => <Navigate href="session" />} />
-              <Route path="/session/:id?" component={SessionRoute} />
+            <Route component={SelectedServerLayout}>
+              <Route path="/" component={HomeRoute} />
+              <Route path="/:dir" component={DirectoryLayout}>
+                <Route path="/" component={() => <Navigate href="session" />} />
+                <Route path="/session/:id?" component={SessionRoute} />
+              </Route>
+            </Route>
+            <Route component={DraftServerLayout}>
+              <Route path="/new-session" component={DraftRoute} />
             </Route>
           </Dynamic>
         </ConnectionGate>

+ 11 - 11
packages/app/src/components/dialog-connect-provider.tsx

@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
   })
 
   const provider = createMemo(
-    () => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
+    () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
   )
   const fallback = createMemo<ProviderAuthMethod[]>(() => [
     {
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
   const [auth] = createResource(
     () => props.provider,
     async () => {
-      const cached = serverSync.data.provider_auth[props.provider]
+      const cached = serverSync().data.provider_auth[props.provider]
       if (cached) return cached
-      const res = await serverSDK.client.provider.auth()
+      const res = await serverSDK().client.provider.auth()
       if (!alive.value) return fallback()
-      serverSync.set("provider_auth", res.data ?? {})
+      serverSync().set("provider_auth", res.data ?? {})
       return res.data?.[props.provider] ?? fallback()
     },
   )
-  const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
-  const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
+  const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
+  const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       }
       dispatch({ type: "auth.pending" })
       const start = Date.now()
-      await serverSDK.client.provider.oauth
+      await serverSDK().client.provider.oauth
         .authorize(
           {
             providerID: props.provider,
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
   })
 
   async function complete() {
-    await serverSDK.client.global.dispose()
+    await serverSDK().client.global.dispose()
     dialog.close()
     showToast({
       variant: "success",
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       }
 
       setFormStore("error", undefined)
-      await serverSDK.client.auth.set({
+      await serverSDK().client.auth.set({
         providerID: props.provider,
         auth: {
           type: "api",
@@ -480,7 +480,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       }
 
       setFormStore("error", undefined)
-      const result = await serverSDK.client.provider.oauth
+      const result = await serverSDK().client.provider.oauth
         .callback({
           providerID: props.provider,
           method: store.methodIndex,
@@ -533,7 +533,7 @@ export function DialogConnectProvider(props: { provider: string }) {
 
     onMount(() => {
       void (async () => {
-        const result = await serverSDK.client.provider.oauth
+        const result = await serverSDK().client.provider.oauth
           .callback({
             providerID: props.provider,
             method: store.methodIndex,

+ 5 - 5
packages/app/src/components/dialog-custom-provider.tsx

@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
     const output = validateCustomProvider({
       form,
       t: language.t,
-      disabledProviders: serverSync.data.config.disabled_providers ?? [],
-      existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
+      disabledProviders: serverSync().data.config.disabled_providers ?? [],
+      existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
     })
     batch(() => {
       setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
 
   const saveMutation = useMutation(() => ({
     mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
-      const disabledProviders = serverSync.data.config.disabled_providers ?? []
+      const disabledProviders = serverSync().data.config.disabled_providers ?? []
       const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
 
       if (result.key) {
-        await serverSDK.client.auth.set({
+        await serverSDK().client.auth.set({
           providerID: result.providerID,
           auth: {
             type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
         })
       }
 
-      await serverSync.updateConfig({
+      await serverSync().updateConfig({
         provider: { [result.providerID]: result.config },
         disabled_providers: nextDisabled,
       })

+ 6 - 6
packages/app/src/components/dialog-fork.tsx

@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
     const sessionID = params.id
     if (!sessionID) return []
 
-    const msgs = sync.data.message[sessionID] ?? []
+    const msgs = sync().data.message[sessionID] ?? []
     const result: ForkableMessage[] = []
 
     for (const message of msgs) {
       if (message.role !== "user") continue
 
-      const parts = sync.data.part[message.id] ?? []
+      const parts = sync().data.part[message.id] ?? []
       const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
       if (!textPart) continue
 
@@ -61,14 +61,14 @@ export const DialogFork: Component = () => {
     const sessionID = params.id
     if (!sessionID) return
 
-    const parts = sync.data.part[item.id] ?? []
+    const parts = sync().data.part[item.id] ?? []
     const restored = extractPromptFromParts(parts, {
-      directory: sdk.directory,
+      directory: sdk().directory,
       attachmentName: language.t("common.attachment"),
     })
-    const dir = base64Encode(sdk.directory)
+    const dir = base64Encode(sdk().directory)
 
-    sdk.client.session
+    sdk().client.session
       .fork({ sessionID, messageID: item.id })
       .then((forked) => {
         if (!forked.data) {

+ 5 - 5
packages/app/src/components/dialog-select-file.tsx

@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
 import { useNavigate } from "@solidjs/router"
 import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
 import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
-import { useServerSDK } from "@/context/server-sdk"
+import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
 function createSessionEntries(props: {
   workspaces: () => string[]
   label: (directory: string) => string
-  serverSDK: ReturnType<typeof useServerSDK>
+  serverSDK: ServerSDK
   language: ReturnType<typeof useLanguage>
 }) {
   const state: {
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     if (directory && !dirs.includes(directory)) return [...dirs, directory]
     return dirs
   })
-  const homedir = createMemo(() => serverSync.data.path.home)
+  const homedir = createMemo(() => serverSync().data.path.home)
   const label = (directory: string) => {
     const current = project()
     const kind =
       current && directory === current.worktree
         ? language.t("workspace.type.local")
         : language.t("workspace.type.sandbox")
-    const [store] = serverSync.child(directory, { bootstrap: false })
+    const [store] = serverSync().child(directory, { bootstrap: false })
     const home = homedir()
     const path = home ? directory.replace(home, "~") : directory
     const name = store.vcs?.branch ?? getFilename(directory)
     return `${kind} : ${name || path}`
   }
 
-  const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
+  const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
 
   const items = async (text: string) => {
     const query = text.trim()

+ 2 - 2
packages/app/src/components/dialog-select-mcp.tsx

@@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
   const language = useLanguage()
 
   const items = createMemo(() =>
-    Object.entries(sync.data.mcp ?? {})
+    Object.entries(sync().data.mcp ?? {})
       .map(([name, status]) => ({ name, status: status.status }))
       .sort((a, b) => a.name.localeCompare(b.name)),
   )
@@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
         }}
       >
         {(i) => {
-          const mcpStatus = () => sync.data.mcp[i.name]
+          const mcpStatus = () => sync().data.mcp[i.name]
           const status = () => mcpStatus()?.status
           const statusLabel = () => {
             const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined

+ 14 - 14
packages/app/src/components/prompt-input.tsx

@@ -207,7 +207,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const sessionID = params.id
     if (!sessionID) return false
 
-    const diffs = sync.data.session_diff[sessionID]
+    const diffs = sync().data.session_diff[sessionID]
     if (!diffs) return false
     return diffs.some((diff) => diff.file === path)
   }
@@ -269,8 +269,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     return paths
   })
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const working = createMemo(() => sync.data.session_working(params.id ?? ""))
+  const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
+  const working = createMemo(() => sync().data.session_working(params.id ?? ""))
   const imageAttachments = createMemo(() =>
     prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
   )
@@ -349,7 +349,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const hasUserPrompt = createMemo(() => {
     const sessionID = params.id
     if (!sessionID) return false
-    const messages = sync.data.message[sessionID]
+    const messages = sync().data.message[sessionID]
     if (!messages) return false
     return messages.some((m) => m.role === "user")
   })
@@ -474,7 +474,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const pick = () => {
     pickAttachmentFiles({
       picker: platform.openAttachmentPickerDialog,
-      directory: () => sdk.directory,
+      directory: () => sdk().directory,
       fallback: () => fileInputRef?.click(),
       onFile: addAttachment,
       onError: (error) =>
@@ -603,8 +603,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const agentList = createMemo(() =>
-    sync.data.agent
-      .filter((agent) => !agent.hidden && agent.mode !== "primary")
+    sync()
+      .data.agent.filter((agent) => !agent.hidden && agent.mode !== "primary")
       .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
   )
   const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
@@ -673,7 +673,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         type: "builtin" as const,
       }))
 
-    const custom = sync.data.command.map((cmd) => ({
+    const custom = sync().data.command.map((cmd) => ({
       id: `custom.${cmd.name}`,
       trigger: cmd.name,
       title: cmd.name,
@@ -1127,8 +1127,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
   const accepting = createMemo(() => {
     const id = params.id
-    if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
-    return permission.isAutoAccepting(id, sdk.directory)
+    if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
+    return permission.isAutoAccepting(id, sdk().directory)
   })
 
   const { abort, handleSubmit } = createPromptSubmit({
@@ -1319,9 +1319,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
     queries: [
-      queryOptions.agents(pathKey(sdk.directory)),
-      queryOptions.providers(null),
-      queryOptions.providers(pathKey(sdk.directory)),
+      queryOptions().agents(pathKey(sdk().directory)),
+      queryOptions().providers(null),
+      queryOptions().providers(pathKey(sdk().directory)),
     ],
   }))
 
@@ -1366,7 +1366,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
     )
   }
-  const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
+  const selectedProject = createMemo(() => projectForDirectory(sdk().directory))
   const projectResults = createMemo(() => {
     const search = picker.projectSearch.trim().toLowerCase()
     if (!search) return projects()

+ 3 - 3
packages/app/src/components/prompt-input/submit.test.ts

@@ -147,12 +147,12 @@ beforeAll(async () => {
           return clientFor(opts.directory)
         },
       }
-      return sdk
+      return () => sdk
     },
   }))
 
   mock.module("@/context/sync", () => ({
-    useSync: () => ({
+    useSync: () => () => ({
       data: { command: [] },
       session: {
         optimistic: {
@@ -176,7 +176,7 @@ beforeAll(async () => {
   }))
 
   mock.module("@/context/server-sync", () => ({
-    useServerSync: () => ({
+    useServerSync: () => () => ({
       child: (directory: string) => {
         syncedDirectories.push(directory)
         storedSessions[directory] ??= []

+ 25 - 25
packages/app/src/components/prompt-input/submit.ts

@@ -7,14 +7,14 @@ import { batch, type Accessor } from "solid-js"
 import type { FileSelection } from "@/context/file"
 import { useServer } from "@/context/server"
 import { useTabs } from "@/context/tabs"
-import { useServerSync } from "@/context/server-sync"
+import { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
 import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
+import { useSDK, type DirectorySDK } from "@/context/sdk"
+import { useSync, type DirectorySync } from "@/context/sync"
 import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { buildRequestParts } from "./build-request-parts"
@@ -40,9 +40,9 @@ export type FollowupDraft = {
 }
 
 type FollowupSendInput = {
-  client: ReturnType<typeof useSDK>["client"]
-  serverSync: ReturnType<typeof useServerSync>
-  sync: ReturnType<typeof useSync>
+  client: DirectorySDK["client"]
+  serverSync: ServerSync
+  sync: DirectorySync
   draft: FollowupDraft
   messageID?: string
   optimisticBusy?: boolean
@@ -218,7 +218,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const [search] = useSearchParams<{ draftId?: string }>()
   const server = useServer()
   const tabs = useTabs()
-  const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
+  const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
 
   const errorMessage = (err: unknown) => {
     if (err && typeof err === "object" && "data" in err) {
@@ -233,8 +233,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     const sessionID = params.id
     if (!sessionID) return Promise.resolve()
 
-    serverSync.todo.set(sessionID, [])
-    const [, setStore] = serverSync.child(sdk.directory)
+    serverSync().todo.set(sessionID, [])
+    const [, setStore] = serverSync().child(sdk().directory)
     setStore("todo", sessionID, [])
 
     input.onAbort?.()
@@ -247,7 +247,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       pending.delete(key)
       return Promise.resolve()
     }
-    return sdk.client.session
+    return sdk().client.session
       .abort({
         sessionID,
       })
@@ -281,7 +281,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   }
 
   const seed = (dir: string, info: Session) => {
-    const [, setStore] = serverSync.child(dir)
+    const [, setStore] = serverSync().child(dir)
     setStore("session", (list: Session[]) => {
       const result = Binary.search(list, info.id, (item) => item.id)
       const next = [...list]
@@ -321,13 +321,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     input.addToHistory(currentPrompt, mode)
     input.resetHistoryNavigation()
 
-    const projectDirectory = sdk.directory
+    const projectDirectory = sdk().directory
     const isNewSession = !params.id
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
 
     let sessionDirectory = projectDirectory
-    let client = sdk.client
+    let client = sdk().client
 
     if (isNewSession) {
       if (worktreeSelection === "create") {
@@ -349,7 +349,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           })
           return
         }
-        WorktreeState.pending(sdk.scope, createdWorktree.directory)
+        WorktreeState.pending(sdk().scope, createdWorktree.directory)
         sessionDirectory = createdWorktree.directory
       }
 
@@ -358,11 +358,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
 
       if (sessionDirectory !== projectDirectory) {
-        client = sdk.createClient({
+        client = sdk().createClient({
           directory: sessionDirectory,
           throwOnError: true,
         })
-        serverSync.child(sessionDirectory)
+        serverSync().child(sessionDirectory)
       }
 
       input.onNewSessionWorktreeReset?.()
@@ -470,7 +470,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     if (text.startsWith("/")) {
       const [cmdName, ...args] = text.split(" ")
       const commandName = cmdName.slice(1)
-      const customCommand = sync.data.command.find((c) => c.name === commandName)
+      const customCommand = sync().data.command.find((c) => c.name === commandName)
       if (customCommand) {
         clearInput()
         client.session
@@ -504,7 +504,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     const messageID = Identifier.ascending("message")
 
     const removeOptimisticMessage = () => {
-      sync.session.optimistic.remove({
+      sync().session.optimistic.remove({
         directory: sessionDirectory,
         sessionID: session.id,
         messageID,
@@ -515,17 +515,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     clearInput()
 
     const waitForWorktree = async () => {
-      const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
+      const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
       if (!worktree || worktree.status !== "pending") return true
 
       if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "busy" })
+        sync().set("session_status", session.id, { type: "busy" })
       }
 
       const controller = new AbortController()
       const cleanup = () => {
         if (sessionDirectory === projectDirectory) {
-          sync.set("session_status", session.id, { type: "idle" })
+          sync().set("session_status", session.id, { type: "idle" })
         }
         removeOptimisticMessage()
         restoreCommentItems(commentItems)
@@ -559,7 +559,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         }, timeoutMs)
       })
 
-      const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
+      const result = await Promise.race([WorktreeState.wait(sdk().scope, sessionDirectory), abortWait, timeout]).finally(
         () => {
           if (timer.id === undefined) return
           clearTimeout(timer.id)
@@ -573,8 +573,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     void sendFollowupDraft({
       client,
-      sync,
-      serverSync,
+      sync: sync(),
+      serverSync: serverSync(),
       draft,
       messageID,
       optimisticBusy: sessionDirectory === projectDirectory,
@@ -582,7 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }).catch((err) => {
       pending.delete(pendingKey(session.id))
       if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "idle" })
+        sync().set("session_status", session.id, { type: "idle" })
       }
       showToast({
         title: language.t("prompt.toast.promptSendFailed.title"),

+ 1 - 1
packages/app/src/components/session-context-usage.tsx

@@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
     pathFromTab: file.pathFromTab,
     normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
   })
-  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+  const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
 
   const usd = createMemo(
     () =>

+ 4 - 4
packages/app/src/components/session/session-context-tab.tsx

@@ -96,13 +96,13 @@ export function SessionContextTab() {
   const providers = useProviders()
   const { params, view } = useSessionLayout()
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
 
   const messages = createMemo(
     () => {
       const id = params.id
       if (!id) return emptyMessages
-      return (sync.data.message[id] ?? []) as Message[]
+      return (sync().data.message[id] ?? []) as Message[]
     },
     emptyMessages,
     { equals: same },
@@ -180,7 +180,7 @@ export function SessionContextTab() {
         if (!c?.input) return []
         return estimateSessionContextBreakdown({
           messages: messages(),
-          parts: sync.data.part as Record<string, Part[] | undefined>,
+          parts: sync().data.part as Record<string, Part[] | undefined>,
           input: c.input,
           systemPrompt: systemPrompt(),
         })
@@ -221,7 +221,7 @@ export function SessionContextTab() {
   let scroll: HTMLDivElement | undefined
   let frame: number | undefined
   let pending: { x: number; y: number } | undefined
-  const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
+  const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
 
   const restoreScroll = () => {
     const el = scroll

+ 1 - 1
packages/app/src/components/session/session-header.tsx

@@ -229,7 +229,7 @@ export function SessionHeader() {
   )
   const opening = createMemo(() => openRequest.app !== undefined)
   const tint = createMemo(() =>
-    messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
+    messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
   )
   const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
     statusVisible: status(),

+ 6 - 6
packages/app/src/components/session/session-new-view.tsx

@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
   const sdk = useSDK()
   const language = useLanguage()
 
-  const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
+  const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
   const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
   const current = createMemo(() => {
     const selection = props.worktree
     if (options().includes(selection)) return selection
     return MAIN_WORKTREE
   })
-  const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
+  const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
   const isWorktree = createMemo(() => {
-    const project = sync.project
+    const project = sync().project
     if (!project) return false
-    return sdk.directory !== project.worktree
+    return sdk().directory !== project.worktree
   })
 
   const label = (value: string) => {
     if (value === MAIN_WORKTREE) {
       if (isWorktree()) return language.t("session.new.worktree.main")
-      const branch = sync.data.vcs?.branch
+      const branch = sync().data.vcs?.branch
       if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
       return language.t("session.new.worktree.main")
     }
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
                 {label(current())}
               </div>
             </div>
-            <Show when={sync.project}>
+            <Show when={sync().project}>
               {(project) => (
                 <div class="flex items-start justify-center gap-3 min-h-5">
                   <div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">

+ 4 - 4
packages/app/src/components/settings-general.tsx

@@ -127,7 +127,7 @@ export const SettingsGeneral: Component = () => {
 
   const [shells] = createResource(
     () =>
-      serverSdk.client.pty
+      serverSdk().client.pty
         .shells()
         .then((res) => res.data ?? [])
         .catch(() => [] as ShellOption[]),
@@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
   })
 
   const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
-  const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
+  const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
 
   const shellOptions = createMemo<ShellSelectOption[]>(() => {
     const list = shells.latest
-    const current = serverSync.data.config.shell
+    const current = serverSync().data.config.shell
 
     const nameCounts = new Map<string, number>()
     for (const s of list) {
@@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
             onSelect={(option) => {
               if (!option) return
               if (option.value === currentShell()) return
-              serverSync.updateConfig({ shell: option.value })
+              serverSync().updateConfig({ shell: option.value })
             }}
             variant="secondary"
             size="small"

+ 8 - 8
packages/app/src/components/settings-providers.tsx

@@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
   const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
   const isConfigCustom = (providerID: string) => {
-    const provider = serverSync.data.config.provider?.[providerID]
+    const provider = serverSync().data.config.provider?.[providerID]
     if (!provider) return false
     if (provider.npm !== "@ai-sdk/openai-compatible") return false
     if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -91,11 +91,11 @@ const SettingsProvidersContent: Component = () => {
   }
 
   const disableProvider = async (providerID: string, name: string) => {
-    const before = serverSync.data.config.disabled_providers ?? []
+    const before = serverSync().data.config.disabled_providers ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
-    serverSync.set("config", "disabled_providers", next)
+    serverSync().set("config", "disabled_providers", next)
 
-    await serverSync
+    await serverSync()
       .updateConfig({ disabled_providers: next })
       .then(() => {
         showToast({
@@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
         })
       })
       .catch((err: unknown) => {
-        serverSync.set("config", "disabled_providers", before)
+        serverSync().set("config", "disabled_providers", before)
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
@@ -114,14 +114,14 @@ const SettingsProvidersContent: Component = () => {
 
   const disconnect = async (providerID: string, name: string) => {
     if (isConfigCustom(providerID)) {
-      await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
+      await serverSDK().client.auth.remove({ providerID }).catch(() => undefined)
       await disableProvider(providerID, name)
       return
     }
-    await serverSDK.client.auth
+    await serverSDK().client.auth
       .remove({ providerID })
       .then(async () => {
-        await serverSDK.client.global.dispose()
+        await serverSDK().client.global.dispose()
         showToast({
           variant: "success",
           icon: "circle-check",

+ 1 - 1
packages/app/src/components/settings-server-picker.tsx

@@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
 
   return (
     <QueryClientProvider client={serverCtx().queryClient}>
-      <ServerSDKProvider server={props.server}>
+      <ServerSDKProvider server={() => props.server}>
         <ServerSyncProvider>
           <ModelsProvider>{props.children}</ModelsProvider>
         </ServerSyncProvider>

+ 4 - 4
packages/app/src/components/settings-v2/general.tsx

@@ -126,7 +126,7 @@ export const SettingsGeneralV2: Component = () => {
 
   const [shells] = createResource(
     () =>
-      serverSdk.client.pty
+      serverSdk().client.pty
         .shells()
         .then((res) => res.data ?? [])
         .catch(() => [] as ShellOption[]),
@@ -144,11 +144,11 @@ export const SettingsGeneralV2: Component = () => {
   })
 
   const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
-  const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
+  const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
 
   const shellOptions = createMemo<ShellSelectOption[]>(() => {
     const list = shells.latest
-    const current = serverSync.data.config.shell
+    const current = serverSync().data.config.shell
 
     const nameCounts = new Map<string, number>()
     for (const s of list) {
@@ -275,7 +275,7 @@ export const SettingsGeneralV2: Component = () => {
             onSelect={(option) => {
               if (!option) return
               if (option.value === currentShell()) return
-              serverSync.updateConfig({ shell: option.value })
+              serverSync().updateConfig({ shell: option.value })
             }}
           />
         </SettingsRowV2>

+ 8 - 8
packages/app/src/components/settings-v2/providers.tsx

@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
   const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
   const isConfigCustom = (providerID: string) => {
-    const provider = serverSync.data.config.provider?.[providerID]
+    const provider = serverSync().data.config.provider?.[providerID]
     if (!provider) return false
     if (provider.npm !== "@ai-sdk/openai-compatible") return false
     if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
   }
 
   const disableProvider = async (providerID: string, name: string) => {
-    const before = serverSync.data.config.disabled_providers ?? []
+    const before = serverSync().data.config.disabled_providers ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
-    serverSync.set("config", "disabled_providers", next)
+    serverSync().set("config", "disabled_providers", next)
 
-    await serverSync
+    await serverSync()
       .updateConfig({ disabled_providers: next })
       .then(() => {
         showToast({
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
         })
       })
       .catch((err: unknown) => {
-        serverSync.set("config", "disabled_providers", before)
+        serverSync().set("config", "disabled_providers", before)
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
@@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
 
   const disconnect = async (providerID: string, name: string) => {
     if (isConfigCustom(providerID)) {
-      await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
+      await serverSdk().client.auth.remove({ providerID }).catch(() => undefined)
       await disableProvider(providerID, name)
       return
     }
-    await serverSdk.client.auth
+    await serverSdk().client.auth
       .remove({ providerID })
       .then(async () => {
-        await serverSdk.client.global.dispose()
+        await serverSdk().client.global.dispose()
         showToast({
           variant: "success",
           icon: "circle-check",

+ 4 - 4
packages/app/src/components/status-popover-body.tsx

@@ -279,13 +279,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
   const toggleMcp = useMcpToggle()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
-  const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
-  const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
+  const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
+  const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
   const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
-  const lspItems = createMemo(() => sync.data.lsp ?? [])
+  const lspItems = createMemo(() => sync().data.lsp ?? [])
   const lspCount = createMemo(() => lspItems().length)
   const plugins = createMemo(() =>
-    (sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
+    (sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
   )
   const pluginCount = createMemo(() => plugins().length)
   const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))

+ 4 - 4
packages/app/src/components/status-popover.tsx

@@ -18,9 +18,9 @@ export function StatusPopover() {
   const global = useGlobal()
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
-  const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
+  const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
   const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync.data.mcp ?? {})
+    const mcp = Object.values(sync().data.mcp ?? {})
     const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
     const warn = mcp.some((item) => item.status === "needs_auth")
     if (failed) return "critical" as const
@@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
   const serverHealth = () => global.servers.health[server.key]?.healthy
-  const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
+  const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
   const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync.data.mcp ?? {})
+    const mcp = Object.values(sync().data.mcp ?? {})
     const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
     const warn = mcp.some((item) => item.status === "needs_auth")
     if (failed) return "critical" as const

+ 3 - 3
packages/app/src/components/terminal.tsx

@@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
   const theme = useTheme()
   const language = useLanguage()
   const server = useServer()
-  const directory = sdk.directory
-  const client = sdk.client
-  const url = sdk.url
+  const directory = sdk().directory
+  const client = sdk().client
+  const url = sdk().url
   const auth = server.current?.http
   const username = auth?.username ?? "opencode"
   const password = auth?.password ?? ""

+ 2 - 2
packages/app/src/components/titlebar.tsx

@@ -291,7 +291,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
                 )
                 if (main) return main
-                const sync = serverSync.createDirSyncContext(route.dir)
+                const sync = serverSync().createDirSyncContext(route.dir)
                 const session = sync.session.get(route.sessionId)
                 if (session?.parentID) {
                   const parentID = session.parentID
@@ -312,7 +312,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               if (tab) return
 
               if (route.type === "session") {
-                const sync = serverSync.createDirSyncContext(route.dir)
+                const sync = serverSync().createDirSyncContext(route.dir)
                 const session = sync.session.get(route.sessionId)
                 if (!session) return
                 const sessionId = session.parentID ?? session.id

+ 1 - 1
packages/app/src/context/comments.tsx

@@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
         const decoded = decodeSessionKey(key)
         return createRoot((dispose) => ({
           value: createCommentSession(
-            serverSDK.scope,
+            serverSDK().scope,
             decoded.dir,
             decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
           ),

+ 2 - 2
packages/app/src/context/directory-sync.ts

@@ -11,7 +11,7 @@ import {
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
 import { diffs as list, message as clean } from "@/utils/diffs"
-import { createServerSdkContext, useServerSDK } from "./server-sdk"
+import { type createServerSdkContext } from "./server-sdk"
 import { type createServerSyncContextInner } from "./server-sync"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -174,7 +174,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
 export const createDirSyncContext = (
   directory: string,
   serverSync: ReturnType<typeof createServerSyncContextInner>,
-  serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
+  serverSDK: ReturnType<typeof createServerSdkContext>,
 ) => {
   const client = serverSDK.createClient({ directory, throwOnError: true })
 

+ 7 - 7
packages/app/src/context/file.tsx

@@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     const language = useLanguage()
     const layout = useLayout()
 
-    const scope = createMemo(() => sdk.directory)
+    const scope = createMemo(() => sdk().directory)
     const path = createPathHelpers(scope)
     const tabs = layout.tabs(() =>
-      SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
+      SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
     )
 
     const inflight = new Map<string, Promise<void>>()
@@ -78,7 +78,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     const tree = createFileTreeStore({
       scope,
       normalizeDir: path.normalizeDir,
-      list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
+      list: (dir) => sdk().client.file.list({ path: dir }).then((x) => x.data ?? []),
       onError: (message) => {
         showToast({
           variant: "error",
@@ -112,7 +112,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       })
     })
 
-    const viewCache = createFileViewCache(serverSDK.scope)
+    const viewCache = createFileViewCache(serverSDK().scope)
     const view = createMemo(() => viewCache.load(scope(), params.id))
 
     const ensure = (file: string) => {
@@ -176,7 +176,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
       setLoading(file)
 
-      const promise = sdk.client.file
+      const promise = sdk().client.file
         .read({ path: file })
         .then((x) => {
           if (scope() !== directory) return
@@ -200,12 +200,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     }
 
     const search = (query: string, dirs: "true" | "false") =>
-      sdk.client.find.files({ query, dirs }).then(
+      sdk().client.find.files({ query, dirs }).then(
         (x) => (x.data ?? []).map(path.normalize),
         () => [],
       )
 
-    const stop = sdk.event.listen((e) => {
+    const stop = sdk().event.listen((e) => {
       invalidateFromWatcher(e.details, {
         normalize: path.normalize,
         hasFile: (file) => Boolean(store.file[file]),

+ 11 - 11
packages/app/src/context/layout.tsx

@@ -246,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       }
     }
 
-    const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
+    const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
     const [store, setStore, _, ready] = persisted(
       { ...target, migrate },
       createStore({
@@ -409,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     }
 
     function enrich(project: { worktree: string; expanded: boolean }) {
-      const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
+      const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
       const projectID = childStore.project
       const metadata = projectID
-        ? serverSync.data.project.find((x) => x.id === projectID)
-        : serverSync.data.project.find((x) => x.worktree === project.worktree)
+        ? serverSync().data.project.find((x) => x.id === projectID)
+        : serverSync().data.project.find((x) => x.worktree === project.worktree)
 
       // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
       // Without this, different subdirectories of the same git repo would share the same
@@ -427,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
     const roots = createMemo(() => {
       const map = new Map<string, string>()
-      for (const project of serverSync.data.project) {
+      for (const project of serverSync().data.project) {
         const sandboxes = project.sandboxes ?? []
         for (const sandbox of sandboxes) {
           map.set(sandbox, project.worktree)
@@ -493,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     createEffect(() => {
       const projects = enriched()
       if (projects.length === 0) return
-      if (!serverSync.ready) return
+      if (!serverSync().ready) return
 
       for (const project of projects) {
         if (!project.id) continue
         if (project.id === "global") continue
-        serverSync.project.icon(project.worktree, project.icon?.override)
+        serverSync().project.icon(project.worktree, project.icon?.override)
       }
     })
 
@@ -532,11 +532,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         colorRequested.set(worktree, color)
 
         if (project.id === "global") {
-          serverSync.project.meta(worktree, { icon: { color } })
+          serverSync().project.meta(worktree, { icon: { color } })
           continue
         }
 
-        void serverSdk.client.project
+        void serverSdk().client.project
           .update({ projectID: project.id, directory: worktree, icon: { color } })
           .catch(() => {
             if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
@@ -554,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           sessionTimer = undefined
           void Promise.all(
             server.projects.list().map((project) => {
-              return serverSync.project.loadSessions(project.worktree)
+              return serverSync().project.loadSessions(project.worktree)
             }),
           )
         }, 0)
@@ -584,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         open(directory: string) {
           const root = rootFor(directory)
           if (server.projects.list().find((x) => x.worktree === root)) return
-          void serverSync.project.loadSessions(root)
+          void serverSync().project.loadSessions(root)
           server.projects.open(root)
         },
         close(directory: string) {

+ 11 - 10
packages/app/src/context/local.tsx

@@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const models = useModels()
 
     const id = createMemo(() => params.id || undefined)
-    const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
+    const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
     const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
 
     const [saved, setSaved] = persisted(
       {
-        ...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
+        ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
         migrate,
       },
       createStore<Saved>({
@@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const scope = createMemo<State | undefined>(() => {
       const session = id()
       if (!session) return store.draft
-      return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
+      return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
     })
 
     createEffect(() => {
       const session = id()
       if (!session) return
 
-      const key = handoffKey(serverSDK.scope, sdk.directory, session)
+      const key = handoffKey(serverSDK().scope, sdk().directory, session)
       const next = handoff.get(key)
       if (!next) return
       if (saved.session[session] !== undefined) {
@@ -144,8 +144,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     })
 
     const configuredModel = () => {
-      if (!sync.data.config.model) return
-      const [providerID, modelID] = sync.data.config.model.split("/")
+      const configured = sync().data.config.model
+      if (!configured) return
+      const [providerID, modelID] = configured.split("/")
       const model = { providerID, modelID }
       if (validModel(model)) return model
     }
@@ -363,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     }
 
     const result = {
-      slug: createMemo(() => base64Encode(sdk.directory)),
+      slug: createMemo(() => base64Encode(sdk().directory)),
       model,
       agent,
       session: {
@@ -374,13 +375,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           const next = clone(snapshot())
           if (!next) return
 
-          if (dir === sdk.directory) {
+          if (dir === sdk().directory) {
             setSaved("session", session, next)
             setStore("draft", undefined)
             return
           }
 
-          handoff.set(handoffKey(serverSDK.scope, dir, session), next)
+          handoff.set(handoffKey(serverSDK().scope, dir, session), next)
           setStore("draft", undefined)
         },
         restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@@ -388,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (!session) return
           if (msg.sessionID !== session) return
           if (saved.session[session] !== undefined) return
-          if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
+          if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
 
           setSaved("session", session, {
             agent: msg.agent,

+ 1 - 1
packages/app/src/context/mcp.ts

@@ -8,7 +8,7 @@ export function useMcpToggle() {
   const language = useLanguage()
 
   return useMutation(() => ({
-    mutationFn: sync.mcp.toggle,
+    mutationFn: sync().mcp.toggle,
     onError: (error) =>
       showToast({
         variant: "error",

+ 4 - 4
packages/app/src/context/notification.tsx

@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const currentSession = createMemo(() => params.id)
 
     const [store, setStore, _, ready] = persisted(
-      Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
+      Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
       createStore({
         list: [] as Notification[],
       }),
@@ -208,10 +208,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
 
     const lookup = async (directory: string, sessionID?: string) => {
       if (!sessionID) return undefined
-      const [syncStore] = serverSync.child(directory, { bootstrap: false })
+      const [syncStore] = serverSync().child(directory, { bootstrap: false })
       const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
       if (match.found) return syncStore.session[match.index]
-      return serverSDK.client.session
+      return serverSDK().client.session
         .get({ directory, sessionID })
         .then((x) => x.data)
         .catch(() => undefined)
@@ -286,7 +286,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
       })
     }
 
-    const unsub = serverSDK.event.listen((e) => {
+    const unsub = serverSDK().event.listen((e) => {
       const event = e.details
       if (event.type !== "session.idle" && event.type !== "session.error") return
 

+ 10 - 10
packages/app/src/context/permission.tsx

@@ -55,13 +55,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     const permissionsEnabled = createMemo(() => {
       const directory = decode64(params.dir)
       if (!directory) return false
-      const [store] = serverSync.child(directory)
+      const [store] = serverSync().child(directory)
       return hasPermissionPromptRules(store.config.permission)
     })
 
     const [store, setStore, _, ready] = persisted(
       {
-        ...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
+        ...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
         migrate(value) {
           if (!value || typeof value !== "object" || Array.isArray(value)) return value
 
@@ -87,7 +87,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       if (!ready()) return
       const directory = decode64(params.dir)
       if (!directory) return
-      const [childStore] = serverSync.child(directory)
+      const [childStore] = serverSync().child(directory)
       const perm = childStore.config.permission
       if (typeof perm === "string" && perm === "allow") {
         const key = directoryAcceptKey(directory)
@@ -119,7 +119,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     }
 
     const respond: PermissionRespondFn = (input) => {
-      serverSDK.client.permission.respond(input).catch(() => {
+      serverSDK().client.permission.respond(input).catch(() => {
         responded.delete(input.permissionID)
       })
     }
@@ -140,7 +140,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     }
 
     function isAutoAccepting(sessionID: string, directory?: string) {
-      const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
+      const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
       return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
     }
 
@@ -149,7 +149,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     }
 
     function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
-      const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
+      const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
       return autoRespondsPermission(store.autoAccept, session, permission, directory)
     }
 
@@ -160,7 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       return next
     }
 
-    const unsubscribe = serverSDK.event.listen((e) => {
+    const unsubscribe = serverSDK().event.listen((e) => {
       const event = e.details
       if (event?.type !== "permission.asked") return
 
@@ -179,7 +179,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         }),
       )
 
-      serverSDK.client.permission
+      serverSDK().client.permission
         .list({ directory })
         .then((x) => {
           if (!isAutoAcceptingDirectory(directory)) return
@@ -211,7 +211,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         }),
       )
 
-      serverSDK.client.permission
+      serverSDK().client.permission
         .list({ directory })
         .then((x) => {
           if (enableVersion.get(key) !== version) return
@@ -269,7 +269,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       },
       permissionsEnabled,
       isPermissionAllowAll(directory: string) {
-        const [childStore] = serverSync.child(directory)
+        const [childStore] = serverSync().child(directory)
         const perm = childStore.config.permission
         return typeof perm === "string" && perm === "allow"
       },

+ 1 - 1
packages/app/src/context/prompt.tsx

@@ -272,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
 
       const entry = createRoot(
         (dispose) => ({
-          value: createPromptSession(serverSDK.scope, scope),
+          value: createPromptSession(serverSDK().scope, scope),
           dispose,
         }),
         owner,

+ 10 - 4
packages/app/src/context/sdk.tsx

@@ -1,11 +1,17 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useServerSDK } from "./server-sdk"
+import { type Accessor, createMemo } from "solid-js"
+import { type ServerSDK, useServerSDK } from "./server-sdk"
+
+export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
-  init: (props: { directory: string }) => {
+  // Resolves the directory-scoped SDK reactively from the (possibly changing) server.
+  init: (props: { directory: string | Accessor<string> }) => {
     const serverSDK = useServerSDK()
-
-    return serverSDK.createDirSdkContext(props.directory)
+    return createMemo(() => {
+      const directory = typeof props.directory === "function" ? props.directory() : props.directory
+      return serverSDK().createDirSdkContext(directory)
+    })
   },
 })

+ 21 - 11
packages/app/src/context/server-sdk.tsx

@@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import { batch, onCleanup, onMount } from "solid-js"
+import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
 import { createSdkForServer } from "@/utils/server"
 import { useLanguage } from "./language"
 import { usePlatform } from "./platform"
@@ -21,7 +21,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
   start()
 }
 
-export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
+function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -261,21 +261,31 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
   }
 }
 
-export type ServerSDK = ReturnType<typeof createServerSdkContext>
+type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
+export type ServerSDK = ServerSDKBase & {
+  createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
+}
+
+export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
+  const sdk = createServerSdkContextBase(server, scope)
+  return Object.assign(sdk, {
+    createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
+  })
+}
 
 export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
   name: "ServerSDK",
-  init: (props: { server?: ServerConnection.Any }) => {
+  // Returns an accessor so the resolved server can change reactively (e.g. a
+  // /new-session draft retargeting its server) without re-instantiating the subtree.
+  init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
     const global = useGlobal()
     const language = useLanguage()
     const server = useServer()
 
-    const conn = props.server ?? server.current
-    if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
-
-    const ctx = global.createServerCtx(conn)
-    return Object.assign(ctx.sdk, {
-      createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
+    return createMemo<ServerSDK>(() => {
+      const conn = props.server?.() ?? server.current
+      if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
+      return global.createServerCtx(conn).sdk
     })
   },
 })
@@ -284,7 +294,7 @@ type SDKEventMap = {
   [key in Event["type"]]: Extract<Event, { type: key }>
 }
 
-function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
+function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
   const client = serverSDK.createClient({
     directory,
     throwOnError: true,

+ 18 - 15
packages/app/src/context/server-sync.tsx

@@ -1,11 +1,11 @@
 import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
-import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
+import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import type { InitError } from "../pages/error"
-import { ServerSDK, useServerSDK } from "./server-sdk"
+import { ServerSDK } from "./server-sdk"
 import {
   bootstrapDirectory,
   bootstrapGlobal,
@@ -84,8 +84,7 @@ function makeQueryOptionsApi(
 }
 export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
 
-export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
-  const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
+export function createServerSyncContextInner(serverSDK: ServerSDK) {
   const language = useLanguage()
   const owner = getOwner()
   if (!owner) throw new Error("ServerSync must be created within owner")
@@ -507,33 +506,37 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
   }
 }
 
-export function createServerSyncContext(_serverSDK?: ServerSDK) {
-  const inner = createServerSyncContextInner(_serverSDK)
+export function createServerSyncContext(serverSDK: ServerSDK) {
+  const inner = createServerSyncContextInner(serverSDK)
   return Object.assign(inner, {
     createDirSyncContext: createRefCountMap(
-      (dir) => createDirSyncContext(dir, inner, _serverSDK),
+      (dir) => createDirSyncContext(dir, inner, serverSDK),
       (dir) => inner.disableMcp(dir),
       directoryKey,
     ),
   })
 }
 
+export type ServerSync = ReturnType<typeof createServerSyncContext>
+
 export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
   name: "ServerSync",
-  gate: false,
-  init: (props: { server?: ServerConnection.Any }) => {
+  // Returns an accessor so the resolved server can change reactively without
+  // re-instantiating the subtree (mirrors useServerSDK).
+  init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
     const global = useGlobal()
     const language = useLanguage()
     const server = useServer()
 
-    const conn = props.server ?? server.current
-    if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
-    const ctx = global.createServerCtx(conn)
-
-    return ctx.sync
+    return createMemo<ServerSync>(() => {
+      const conn = props.server?.() ?? server.current
+      if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
+      return global.createServerCtx(conn).sync
+    })
   },
 })
 
 export function useQueryOptions() {
-  return useServerSync().queryOptions
+  const sync = useServerSync()
+  return createMemo(() => sync().queryOptions)
 }

+ 4 - 1
packages/app/src/context/sync.tsx

@@ -1,4 +1,5 @@
 import { Binary } from "@opencode-ai/core/util/binary"
+import { createMemo } from "solid-js"
 import { useServerSync } from "./server-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -112,5 +113,7 @@ export const useSync = () => {
   const serverSync = useServerSync()
   const sdk = useSDK()
 
-  return serverSync.createDirSyncContext(sdk.directory)
+  return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
 }
+
+export type DirectorySync = ReturnType<ReturnType<typeof useSync>>

+ 5 - 5
packages/app/src/context/terminal.tsx

@@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
-import { useSDK } from "./sdk"
+import { useSDK, type DirectorySDK } from "./sdk"
 import type { Platform } from "./platform"
 import { useServer } from "./server"
 import { defaultTitle, titleNumber } from "./terminal-title"
@@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
 }
 
 function createWorkspaceTerminalSession(
-  sdk: ReturnType<typeof useSDK>,
+  sdk: DirectorySDK,
   dir: string,
   scope: ServerScopeValue,
   legacySessionID?: string,
@@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
   })
   onCleanup(unsub)
 
-  const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
+  const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
     const index = store.all.findIndex((x) => x.id === pty.id)
     const previous = index >= 0 ? store.all[index] : undefined
     if (index >= 0) {
@@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
       })
   }
 
-  const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
+  const clone = async (client: DirectorySDK["client"], id: string) => {
     const index = store.all.findIndex((x) => x.id === id)
     const pty = store.all[index]
     if (!pty) return
@@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       }
 
       const entry = createRoot((dispose) => ({
-        value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
+        value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
         dispose,
       }))
 

+ 2 - 2
packages/app/src/hooks/use-providers.ts

@@ -22,10 +22,10 @@ export function useProviders() {
   const dir = createMemo(() => decode64(params.dir) ?? "")
   const providers = () => {
     if (dir()) {
-      const [projectStore] = serverSync.child(dir())
+      const [projectStore] = serverSync().child(dir())
       if (projectStore.provider_ready) return projectStore.provider
     }
-    return serverSync.data.provider
+    return serverSync().data.provider
   }
   return {
     all: () => providers().all,

+ 3 - 3
packages/app/src/pages/directory-layout.tsx

@@ -20,7 +20,7 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
   createEffect(() => {
     // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
     if (props.draftID) return
-    const next = sync.data.path.directory
+    const next = sync().data.path.directory
     if (!next || next === props.directory) return
     const path = location.pathname.slice(slug().length + 1)
     navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
@@ -28,12 +28,12 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
 
   createResource(
     () => params.id,
-    (id) => sync.session.sync(id).catch(() => {}),
+    (id) => sync().session.sync(id).catch(() => {}),
   )
 
   return (
     <DataProvider
-      data={sync.data}
+      data={sync().data}
       directory={props.directory}
       onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
       onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}

+ 7 - 7
packages/app/src/pages/home.tsx

@@ -24,7 +24,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
 import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { ServerConnection, useServer } from "@/context/server"
 import { sessionHasOpenTab, useTabs } from "@/context/tabs"
-import { useServerSync } from "@/context/server-sync"
+import { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
 import {
@@ -80,7 +80,7 @@ const HOME_SEARCH_RESULT_META =
 let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
 
 function buildHomeSessionRecords(input: {
-  sync: Pick<ReturnType<typeof useServerSync>, "child">
+  sync: Pick<ServerSync, "child">
   projectDirectories: () => string[]
   projects: () => LocalProject[]
   projectByID: () => Map<string, LocalProject>
@@ -149,7 +149,7 @@ function HomeDesign() {
     if (!conn) return
     return global.createServerCtx(conn)
   })
-  const focusedSync = () => focusedServerCtx()?.sync ?? sync
+  const focusedSync = () => focusedServerCtx()?.sync ?? sync()
   const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
   const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
   const newSessionProject = createMemo(
@@ -1102,9 +1102,9 @@ function LegacyHome() {
   const global = useGlobal()
   const server = useServer()
   const language = useLanguage()
-  const homedir = createMemo(() => sync.data.path.home)
+  const homedir = createMemo(() => sync().data.path.home)
   const recent = createMemo(() => {
-    return sync.data.project
+    return sync().data.project
       .slice()
       .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
       .slice(0, 5)
@@ -1164,7 +1164,7 @@ function LegacyHome() {
         {server.name}
       </Button>
       <Switch>
-        <Match when={sync.data.project.length > 0}>
+        <Match when={sync().data.project.length > 0}>
           <div class="mt-20 w-full flex flex-col gap-4">
             <div class="flex gap-2 items-center justify-between pl-3">
               <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
@@ -1191,7 +1191,7 @@ function LegacyHome() {
             </ul>
           </div>
         </Match>
-        <Match when={!sync.ready}>
+        <Match when={!sync().ready}>
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
             <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
             <Button class="px-3" onClick={chooseProject}>

+ 52 - 52
packages/app/src/pages/layout.tsx

@@ -95,7 +95,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
 export default function Layout(props: ParentProps) {
   const serverSDK = useServerSDK()
   const [store, setStore, , ready] = persisted(
-    Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
+    Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
     createStore({
       lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
       activeProject: undefined as string | undefined,
@@ -140,7 +140,7 @@ export default function Layout(props: ParentProps) {
     if (!slug) return { slug, dir: "" }
     const dir = decode64(slug)
     if (!dir) return { slug, dir: "" }
-    const store = serverSync.peek(dir, { bootstrap: false })
+    const store = serverSync().peek(dir, { bootstrap: false })
     return {
       slug,
       store,
@@ -213,7 +213,7 @@ export default function Layout(props: ParentProps) {
     active: () => state.hoverProject,
     el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
     onActivate: (directory) => {
-      serverSync.child(directory)
+      serverSync().child(directory)
       setState("hoverProject", directory)
     },
   })
@@ -397,17 +397,17 @@ export default function Layout(props: ParentProps) {
         alertedAtBySession.delete(sessionKey)
       }
 
-      const unsub = serverSDK.event.listen((e) => {
+      const unsub = serverSDK().event.listen((e) => {
         if (e.details?.type === "worktree.ready") {
           setBusy(e.name, false)
-          WorktreeState.ready(serverSDK.scope, e.name)
+          WorktreeState.ready(serverSDK().scope, e.name)
           return
         }
 
         if (e.details?.type === "worktree.failed") {
           setBusy(e.name, false)
           WorktreeState.failed(
-            serverSDK.scope,
+            serverSDK().scope,
             e.name,
             e.details.properties?.message ?? language.t("common.requestFailed"),
           )
@@ -435,7 +435,7 @@ export default function Layout(props: ParentProps) {
         const props = e.details.properties
         if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
 
-        const [store] = serverSync.child(directory, { bootstrap: false })
+        const [store] = serverSync().child(directory, { bootstrap: false })
         const session = store.session.find((s) => s.id === props.sessionID)
         const sessionKey = `${directory}:${props.sessionID}`
 
@@ -498,7 +498,7 @@ export default function Layout(props: ParentProps) {
         if (!currentDir() || !currentSession) return
         const sessionKey = `${currentDir()}:${currentSession}`
         dismissSessionAlert(sessionKey)
-        const [store] = serverSync.child(currentDir(), { bootstrap: false })
+        const [store] = serverSync().child(currentDir(), { bootstrap: false })
         const childSessions = store.session.filter((s) => s.parentID === currentSession)
         for (const child of childSessions) {
           dismissSessionAlert(`${currentDir()}:${child.id}`)
@@ -536,11 +536,11 @@ export default function Layout(props: ParentProps) {
     const direct = projects.find((p) => pathKey(p.worktree) === key)
     if (direct) return direct
 
-    const [child] = serverSync.child(directory, { bootstrap: false })
+    const [child] = serverSync().child(directory, { bootstrap: false })
     const id = child.project
     if (!id) return
 
-    const meta = serverSync.data.project.find((p) => p.id === id)
+    const meta = serverSync().data.project.find((p) => p.id === id)
     const root = meta?.worktree
     if (!root) return
 
@@ -631,7 +631,7 @@ export default function Layout(props: ParentProps) {
 
     const result: Session[] = []
     for (const dir of dirs) {
-      const [dirStore] = serverSync.child(dir, { bootstrap: true })
+      const [dirStore] = serverSync().child(dir, { bootstrap: true })
       const dirSessions = sortedRootSessions(dirStore, now)
       result.push(...dirSessions)
     }
@@ -683,10 +683,10 @@ export default function Layout(props: ParentProps) {
 
   createEffect(() => {
     route()
-    serverSDK.url
+    serverSDK().url
 
     prefetchToken.value += 1
-    clearSessionPrefetchInflight(serverSDK.scope)
+    clearSessionPrefetchInflight(serverSDK().scope)
     prefetchQueues.clear()
   })
 
@@ -730,17 +730,17 @@ export default function Layout(props: ParentProps) {
   }
 
   async function prefetchMessages(directory: string, sessionID: string, token: number) {
-    const [store, setStore] = serverSync.child(directory, { bootstrap: false })
+    const [store, setStore] = serverSync().child(directory, { bootstrap: false })
 
     return runSessionPrefetch({
-      scope: serverSDK.scope,
+      scope: serverSDK().scope,
       directory,
       sessionID,
       task: (rev) =>
-        retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
+        retry(() => serverSDK().client.session.messages({ directory, sessionID, limit: prefetchChunk }))
           .then((messages) => {
             if (prefetchToken.value !== token) return
-            if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
+            if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
 
             const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
             const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -755,9 +755,9 @@ export default function Layout(props: ParentProps) {
             }
 
             if (stale.length > 0) {
-              clearSessionPrefetch(serverSDK.scope, directory, stale)
+              clearSessionPrefetch(serverSDK().scope, directory, stale)
               for (const id of stale) {
-                serverSync.todo.set(id, undefined)
+                serverSync().todo.set(id, undefined)
               }
             }
 
@@ -767,7 +767,7 @@ export default function Layout(props: ParentProps) {
               sorted,
             )
 
-            if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
+            if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
 
             batch(() => {
               if (stale.length > 0) {
@@ -779,7 +779,7 @@ export default function Layout(props: ParentProps) {
               }
 
               setStore("message", sessionID, reconcile(merged, { key: "id" }))
-              setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
+              setSessionPrefetch({ scope: serverSDK().scope, directory, sessionID, ...meta })
 
               for (const message of items) {
                 const currentParts = store.part[message.info.id] ?? []
@@ -822,9 +822,9 @@ export default function Layout(props: ParentProps) {
     const directory = session.directory
     if (!directory) return
 
-    const [store] = serverSync.child(directory, { bootstrap: false })
+    const [store] = serverSync().child(directory, { bootstrap: false })
     const cached = untrack(() => {
-      const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
+      const info = getSessionPrefetch(serverSDK().scope, directory, session.id)
       return shouldSkipSessionPrefetch({
         message: store.message[session.id] !== undefined,
         info,
@@ -927,7 +927,7 @@ export default function Layout(props: ParentProps) {
     if (!target) return
 
     // warm up child store to prevent flicker
-    serverSync.child(target.worktree)
+    serverSync().child(target.worktree)
     void openProject(target.worktree)
   }
 
@@ -936,7 +936,7 @@ export default function Layout(props: ParentProps) {
     const target = projects[index]
     if (!target) return
 
-    serverSync.child(target.worktree)
+    serverSync().child(target.worktree)
     void openProject(target.worktree)
   }
 
@@ -965,12 +965,12 @@ export default function Layout(props: ParentProps) {
   }
 
   async function archiveSession(session: Session) {
-    const [store, setStore] = serverSync.child(session.directory)
+    const [store, setStore] = serverSync().child(session.directory)
     const sessions = store.session ?? []
     const index = sessions.findIndex((s) => s.id === session.id)
     const nextSession = sessions[index + 1] ?? sessions[index - 1]
 
-    await serverSDK.client.session.update({
+    await serverSDK().client.session.update({
       directory: session.directory,
       sessionID: session.id,
       time: { archived: Date.now() },
@@ -1241,11 +1241,11 @@ export default function Layout(props: ParentProps) {
     )
     if (known) return known[0]
 
-    const [child] = serverSync.child(directory, { bootstrap: false })
+    const [child] = serverSync().child(directory, { bootstrap: false })
     const id = child.project
     if (!id) return directory
 
-    const meta = serverSync.data.project.find((item) => item.id === id)
+    const meta = serverSync().data.project.find((item) => item.id === id)
     return meta?.worktree ?? directory
   }
 
@@ -1293,7 +1293,7 @@ export default function Layout(props: ParentProps) {
     }
     const refreshDirs = async (target?: string) => {
       if (!target || target === root || canOpen(target)) return canOpen(target)
-      const listed = await serverSDK.client.worktree
+      const listed = await serverSDK().client.worktree
         .list({ directory: root })
         .then((x) => x.data ?? [])
         .catch(() => [] as string[])
@@ -1302,13 +1302,13 @@ export default function Layout(props: ParentProps) {
     }
     const openSession = async (target: { directory: string; id: string }) => {
       if (!canOpen(target.directory)) return false
-      const [data] = serverSync.child(target.directory, { bootstrap: false })
+      const [data] = serverSync().child(target.directory, { bootstrap: false })
       if (data.session.some((item) => item.id === target.id)) {
         setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
         navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
         return true
       }
-      const resolved = await serverSDK.client.session
+      const resolved = await serverSDK().client.session
         .get({ sessionID: target.id })
         .then((x) => x.data)
         .catch(() => undefined)
@@ -1328,7 +1328,7 @@ export default function Layout(props: ParentProps) {
     }
 
     const latest = latestRootSession(
-      dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
+      dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
       Date.now(),
     )
     if (latest && (await openSession(latest))) {
@@ -1339,7 +1339,7 @@ export default function Layout(props: ParentProps) {
       await Promise.all(
         dirs.map(async (item) => ({
           path: { directory: item },
-          session: await serverSDK.client.session
+          session: await serverSDK().client.session
             .list({ directory: item })
             .then((x) => x.data ?? [])
             .catch(() => []),
@@ -1402,11 +1402,11 @@ export default function Layout(props: ParentProps) {
     const name = next === getFilename(project.worktree) ? "" : next
 
     if (project.id && project.id !== "global") {
-      await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
+      await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
       return
     }
 
-    serverSync.project.meta(project.worktree, { name })
+    serverSync().project.meta(project.worktree, { name })
   }
 
   const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
@@ -1495,7 +1495,7 @@ export default function Layout(props: ParentProps) {
 
     setBusy(directory, true)
 
-    const result = await serverSDK.client.worktree
+    const result = await serverSDK().client.worktree
       .remove({ directory: root, worktreeRemoveInput: { directory } })
       .then((x) => x.data)
       .catch((err) => {
@@ -1514,7 +1514,7 @@ export default function Layout(props: ParentProps) {
       clearLastProjectSession(root)
     }
 
-    serverSync.set(
+    serverSync().set(
       "project",
       produce((draft) => {
         const project = draft.find((item) => item.worktree === root)
@@ -1553,7 +1553,7 @@ export default function Layout(props: ParentProps) {
     })
     const dismiss = () => toaster.dismiss(progress)
 
-    const sessions: Session[] = await serverSDK.client.session
+    const sessions: Session[] = await serverSDK().client.session
       .list({ directory })
       .then((x) => x.data ?? [])
       .catch(() => [])
@@ -1562,11 +1562,11 @@ export default function Layout(props: ParentProps) {
       directory,
       sessions.map((s) => s.id),
       platform,
-      serverSDK.scope,
+      serverSDK().scope,
     )
-    await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
+    await serverSDK().client.instance.dispose({ directory }).catch(() => undefined)
 
-    const result = await serverSDK.client.worktree
+    const result = await serverSDK().client.worktree
       .reset({ directory: root, worktreeResetInput: { directory } })
       .then((x) => x.data)
       .catch((err) => {
@@ -1588,7 +1588,7 @@ export default function Layout(props: ParentProps) {
       sessions
         .filter((session) => session.time.archived === undefined)
         .map((session) =>
-          serverSDK.client.session
+          serverSDK().client.session
             .update({
               sessionID: session.id,
               directory: session.directory,
@@ -1629,7 +1629,7 @@ export default function Layout(props: ParentProps) {
     })
 
     onMount(() => {
-      serverSDK.client.vcs
+      serverSDK().client.vcs
         .status({ directory: props.directory })
         .then((x) => {
           const files = x.data ?? []
@@ -1688,7 +1688,7 @@ export default function Layout(props: ParentProps) {
     })
 
     const refresh = async () => {
-      const sessions = await serverSDK.client.session
+      const sessions = await serverSDK().client.session
         .list({ directory: props.directory })
         .then((x) => x.data ?? [])
         .catch(() => [])
@@ -1697,7 +1697,7 @@ export default function Layout(props: ParentProps) {
     }
 
     onMount(() => {
-      serverSDK.client.vcs
+      serverSDK().client.vcs
         .status({ directory: props.directory })
         .then((x) => {
           const files = x.data ?? []
@@ -1831,7 +1831,7 @@ export default function Layout(props: ParentProps) {
         const next = new Set(dirs)
         for (const directory of next) {
           if (loadedSessionDirs.has(directory)) continue
-          void serverSync.project.loadSessions(directory)
+          void serverSync().project.loadSessions(directory)
         }
 
         loadedSessionDirs.clear()
@@ -1876,7 +1876,7 @@ export default function Layout(props: ParentProps) {
       directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
         ? directory
         : undefined
-    const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
+    const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
 
     const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
     if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
@@ -1928,7 +1928,7 @@ export default function Layout(props: ParentProps) {
 
   const createWorkspace = async (project: LocalProject) => {
     clearSidebarHoverState()
-    const created = await serverSDK.client.worktree
+    const created = await serverSDK().client.worktree
       .create({ directory: project.worktree })
       .then((x) => x.data)
       .catch((err) => {
@@ -1948,7 +1948,7 @@ export default function Layout(props: ParentProps) {
     const root = pathKey(local)
 
     setBusy(created.directory, true)
-    WorktreeState.pending(serverSDK.scope, created.directory)
+    WorktreeState.pending(serverSDK().scope, created.directory)
     setStore("workspaceExpanded", key, true)
     if (key !== created.directory) {
       setStore("workspaceExpanded", created.directory, true)
@@ -1962,7 +1962,7 @@ export default function Layout(props: ParentProps) {
       return [created.directory, ...next]
     })
 
-    serverSync.child(created.directory)
+    serverSync().child(created.directory)
     navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
   }
 
@@ -2067,7 +2067,7 @@ export default function Layout(props: ParentProps) {
       if (!item) return false
       return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
     })
-    const homedir = createMemo(() => serverSync.data.path.home)
+    const homedir = createMemo(() => serverSync().data.path.home)
 
     return (
       <div

+ 2 - 2
packages/app/src/pages/layout/project-avatar-state.ts

@@ -14,7 +14,7 @@ export function useSessionTabAvatarState(
   const permission = usePermission()
   const hasPermissions = createMemo(() => {
     if (!active()) return false
-    const [store] = globalSync.child(directory(), { bootstrap: false })
+    const [store] = globalSync().child(directory(), { bootstrap: false })
     return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
       return !permission.autoResponds(item, directory())
     })
@@ -23,7 +23,7 @@ export function useSessionTabAvatarState(
   const loading = createMemo(() => {
     if (!active()) return false
     if (hasPermissions()) return false
-    const [store] = globalSync.child(directory(), { bootstrap: false })
+    const [store] = globalSync().child(directory(), { bootstrap: false })
     return store.session_working(sessionId())
   })
   return { unread, loading }

+ 2 - 2
packages/app/src/pages/layout/sidebar-items.tsx

@@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
   const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
   const hasPermissions = createMemo(() =>
     dirs().some((directory) => {
-      const [store] = serverSync.child(directory, { bootstrap: false })
+      const [store] = serverSync().child(directory, { bootstrap: false })
       return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
     }),
   )
@@ -149,7 +149,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
   const serverSync = useServerSync()
   const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
   const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
-  const [sessionStore] = serverSync.child(props.session.directory)
+  const [sessionStore] = serverSync().child(props.session.directory)
   const hasPermissions = createMemo(() => {
     return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
       return !permission.autoResponds(item, props.session.directory)

+ 4 - 4
packages/app/src/pages/layout/sidebar-project.tsx

@@ -294,23 +294,23 @@ export const SortableProject = (props: {
   const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
 
   const label = (directory: string) => {
-    const [data] = serverSync.child(directory, { bootstrap: false })
+    const [data] = serverSync().child(directory, { bootstrap: false })
     const kind =
       directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
     const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
     return `${kind} : ${name}`
   }
 
-  const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
+  const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
   const isWorking = createMemo(() =>
     dirs().some((directory) => {
-      const [store] = serverSync.child(directory, { bootstrap: false })
+      const [store] = serverSync().child(directory, { bootstrap: false })
       return Object.keys(store.session_status).some((id) => store.session_working(id))
     }),
   )
   const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
   const workspaceSessions = (directory: string) => {
-    const [data] = serverSync.child(directory, { bootstrap: false })
+    const [data] = serverSync().child(directory, { bootstrap: false })
     return sortedRootSessions(data, props.sortNow())
   }
   const tile = () => (

+ 8 - 8
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
     const directory = props.activeWorkspace()
     if (!directory) return
 
-    const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
+    const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
     const kind =
       directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
     const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
@@ -303,7 +303,7 @@ export const SortableWorkspace = (props: {
   const queryOptions = useQueryOptions()
   const language = useLanguage()
   const sortable = createSortable(props.directory)
-  const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
+  const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
   const [menu, setMenu] = createStore({
     open: false,
     pendingRename: false,
@@ -321,14 +321,14 @@ export const SortableWorkspace = (props: {
   const boot = createMemo(() => open() || active())
   const count = createMemo(() => sessions()?.length ?? 0)
   const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
-  const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory)))
+  const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
   const busy = createMemo(() => props.ctx.isBusy(props.directory))
   const loading = () => fetching() > 0 && count() === 0
   const touch = createMediaQuery("(hover: none)")
   const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
   const loadMore = async () => {
     setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
-    await serverSync.project.loadSessions(props.directory)
+    await serverSync().project.loadSessions(props.directory)
   }
 
   const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
 
   createEffect(() => {
     if (!boot()) return
-    serverSync.child(props.directory, { bootstrap: true })
+    serverSync().child(props.directory, { bootstrap: true })
   })
 
   return (
@@ -450,18 +450,18 @@ export const LocalWorkspace = (props: {
   const queryOptions = useQueryOptions()
   const language = useLanguage()
   const workspace = createMemo(() => {
-    const [store, setStore] = serverSync.child(props.project.worktree)
+    const [store, setStore] = serverSync().child(props.project.worktree)
     return { store, setStore }
   })
   const slug = createMemo(() => base64Encode(props.project.worktree))
   const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
   const count = createMemo(() => sessions()?.length ?? 0)
-  const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree)))
+  const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
   const hasMore = createMemo(() => workspace().store.sessionTotal > count())
   const loading = () => fetching() > 0 && count() === 0
   const loadMore = async () => {
     workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
-    await serverSync.project.loadSessions(props.project.worktree)
+    await serverSync().project.loadSessions(props.project.worktree)
   }
 
   return (

+ 2 - 2
packages/app/src/pages/new-session.tsx

@@ -30,8 +30,8 @@ export default function NewSessionPage() {
 
   const newSessionWorktree = createMemo(() => {
     if (store.worktree === "create") return "create"
-    const project = sync.project
-    if (project && sdk.directory !== project.worktree) return sdk.directory
+    const project = sync().project
+    if (project && sdk().directory !== project.worktree) return sdk().directory
     return "main"
   })
 

+ 63 - 63
packages/app/src/pages/session.tsx

@@ -308,10 +308,10 @@ export default function Page() {
     if (!view().reviewPanel.opened()) view().reviewPanel.open()
   }
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
   const isChildSession = createMemo(() => !!info()?.parentID)
-  const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
-  const canReview = createMemo(() => !!sync.project)
+  const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
+  const canReview = createMemo(() => !!sync().project)
   const reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
     tabs,
@@ -323,21 +323,21 @@ export default function Page() {
   const activeTab = tabState.activeTab
   const activeFileTab = tabState.activeFileTab
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
-  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+  const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
   const messagesReady = createMemo(() => {
     const id = params.id
     if (!id) return true
-    return sync.data.message[id] !== undefined
+    return sync().data.message[id] !== undefined
   })
   const historyMore = createMemo(() => {
     const id = params.id
     if (!id) return false
-    return sync.session.history.more(id)
+    return sync().session.history.more(id)
   })
   const historyLoading = createMemo(() => {
     const id = params.id
     if (!id) return false
-    return sync.session.history.loading(id)
+    return sync().session.history.loading(id)
   })
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -397,7 +397,7 @@ export default function Page() {
   })
 
   const [followup, setFollowup] = persisted(
-    Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
+    Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
     createStore<{
       items: Record<string, FollowupItem[] | undefined>
       failed: Record<string, string | undefined>
@@ -444,16 +444,16 @@ export default function Page() {
   }, desktopReviewOpen())
 
   const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
-  const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
+  const nogit = createMemo(() => {
+    const project = sync().project
+    return !!project && project.vcs !== "git"
+  })
   const changesOptions = createMemo<ChangeMode[]>(() => {
     const list: ChangeMode[] = []
-    if (sync.project?.vcs === "git") list.push("git")
-    if (
-      sync.project?.vcs === "git" &&
-      sync.data.vcs?.branch &&
-      sync.data.vcs?.default_branch &&
-      sync.data.vcs.branch !== sync.data.vcs.default_branch
-    ) {
+    const project = sync().project
+    const vcs = sync().data.vcs
+    if (project?.vcs === "git") list.push("git")
+    if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
       list.push("branch")
     }
     list.push("turn")
@@ -469,18 +469,18 @@ export default function Page() {
     if (store.changes === "git" || store.changes === "branch") return store.changes
   })
   const vcsKey = createMemo(
-    () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
+    () => ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
   )
   const vcsQuery = createQuery(() => {
     const mode = vcsMode()
-    const enabled = wantsReview() && sync.project?.vcs === "git"
+    const enabled = wantsReview() && sync().project?.vcs === "git"
 
     return {
       queryKey: [...vcsKey(), mode] as const,
       enabled,
       queryFn: mode
         ? () =>
-            sdk.client.vcs
+            sdk().client.vcs
               .diff({ mode })
               .then((result) => list(result.data))
               .catch((error) => {
@@ -506,8 +506,8 @@ export default function Page() {
 
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
-    const project = sync.project
-    if (project && sdk.directory !== project.worktree) return sdk.directory
+    const project = sync().project
+    if (project && sdk().directory !== project.worktree) return sdk().directory
     return "main"
   })
 
@@ -569,11 +569,11 @@ export default function Page() {
   }
 
   function upsert(next: Project) {
-    const list = serverSync.data.project
-    sync.set("project", next.id)
+    const list = serverSync().data.project
+    sync().set("project", next.id)
     const idx = list.findIndex((item) => item.id === next.id)
     if (idx >= 0) {
-      serverSync.set(
+      serverSync().set(
         "project",
         list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
       )
@@ -581,14 +581,14 @@ export default function Page() {
     }
     const at = list.findIndex((item) => item.id > next.id)
     if (at >= 0) {
-      serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
+      serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
       return
     }
-    serverSync.set("project", [...list, next])
+    serverSync().set("project", [...list, next])
   }
 
   const gitMutation = useMutation(() => ({
-    mutationFn: () => sdk.client.project.initGit(),
+    mutationFn: () => sdk().client.project.initGit(),
     onSuccess: (x) => {
       if (!x.data) return
       upsert(x.data)
@@ -632,7 +632,7 @@ export default function Page() {
   const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
 
   const [sessionSync] = createResource(
-    () => [sdk.directory, params.id] as const,
+    () => [sdk().directory, params.id] as const,
     ([directory, id]) => {
       if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
       if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
@@ -640,11 +640,11 @@ export default function Page() {
       refreshTimer = undefined
       if (!id) return
 
-      const cached = untrack(() => sync.data.message[id] !== undefined)
+      const cached = untrack(() => sync().data.message[id] !== undefined)
       const stale = !cached
         ? false
         : (() => {
-            const info = getSessionPrefetch(serverSDK.scope, directory, id)
+            const info = getSessionPrefetch(serverSDK().scope, directory, id)
             if (!info) return true
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()
@@ -655,12 +655,12 @@ export default function Page() {
           refreshTimer = undefined
           if (params.id !== id) return
           untrack(() => {
-            if (stale) void sync.session.sync(id, { force: true })
+            if (stale) void sync().session.sync(id, { force: true })
           })
         }, 0)
       })
 
-      return sync.session.sync(id)
+      return sync().session.sync(id)
     },
   )
 
@@ -669,9 +669,9 @@ export default function Page() {
       () => {
         const id = params.id
         return [
-          sdk.directory,
+          sdk().directory,
           id,
-          id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
+          id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
           id ? composer.blocked() : false,
         ] as const
       },
@@ -682,15 +682,15 @@ export default function Page() {
         todoTimer = undefined
         if (!id) return
         if (status === "idle" && !blocked) return
-        const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
+        const cached = untrack(() => sync().data.todo[id] !== undefined || serverSync().data.session_todo[id] !== undefined)
 
         todoFrame = requestAnimationFrame(() => {
           todoFrame = undefined
           todoTimer = window.setTimeout(() => {
             todoTimer = undefined
-            if (sdk.directory !== dir || params.id !== id) return
+            if (sdk().directory !== dir || params.id !== id) return
             untrack(() => {
-              void sync.session.todo(id, cached ? { force: true } : undefined)
+              void sync().session.todo(id, cached ? { force: true } : undefined)
             })
           }, 0)
         })
@@ -723,7 +723,7 @@ export default function Page() {
     ),
   )
 
-  const stopVcs = sdk.event.listen((evt) => {
+  const stopVcs = sdk().event.listen((evt) => {
     if (evt.details.type !== "file.watcher.updated") return
     const props =
       typeof evt.details.properties === "object" && evt.details.properties
@@ -866,7 +866,7 @@ export default function Page() {
 
   createEffect(
     on(
-      () => sync.data.session_status[params.id ?? ""]?.type,
+      () => sync().data.session_status[params.id ?? ""]?.type,
       (next, prev) => {
         if (next !== "idle" || prev === undefined || prev === "idle") return
         refreshVcs()
@@ -1137,10 +1137,10 @@ export default function Page() {
     if (!id) return
 
     if (!wantsReview()) return
-    if (sync.data.session_diff[id] !== undefined) return
-    if (sync.status === "loading") return
+    if (sync().data.session_diff[id] !== undefined) return
+    if (sync().status === "loading") return
 
-    void sync.session.diff(id)
+    void sync().session.diff(id)
   })
 
   createEffect(
@@ -1155,14 +1155,14 @@ export default function Page() {
 
         const id = params.id
         if (!id) return
-        if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
+        if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
 
         diffFrame = requestAnimationFrame(() => {
           diffFrame = undefined
           diffTimer = window.setTimeout(() => {
             diffTimer = undefined
             if (sessionKey() !== key) return
-            void sync.session.diff(id, { force: true })
+            void sync().session.diff(id, { force: true })
           }, 0)
         })
       },
@@ -1172,10 +1172,10 @@ export default function Page() {
 
   let treeDir: string | undefined
   createEffect(() => {
-    const dir = sdk.directory
+    const dir = sdk().directory
     if (!isDesktop()) return
     if (!layout.fileTree.opened()) return
-    if (sync.status === "loading") return
+    if (sync().status === "loading") return
 
     fileTreeTab()
     const refresh = treeDir !== dir
@@ -1185,7 +1185,7 @@ export default function Page() {
 
   createEffect(
     on(
-      () => sdk.directory,
+      () => sdk().directory,
       () => {
         const tab = activeFileTab()
         if (!tab) return
@@ -1285,7 +1285,7 @@ export default function Page() {
     visibleUserMessages,
     historyMore,
     historyLoading,
-    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
+    loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
     userScrolled: autoScroll.userScrolled,
     scroller: () => scroller,
   })
@@ -1329,8 +1329,8 @@ export default function Page() {
   )
 
   const draft = (id: string) =>
-    extractPromptFromParts(sync.data.part[id] ?? [], {
-      directory: sdk.directory,
+    extractPromptFromParts(sync().data.part[id] ?? [], {
+      directory: sdk().directory,
       attachmentName: language.t("common.attachment"),
     })
 
@@ -1353,7 +1353,7 @@ export default function Page() {
   }
 
   const merge = (next: NonNullable<ReturnType<typeof info>>) =>
-    sync.set("session", (list) => {
+    sync().set("session", (list) => {
       const idx = list.findIndex((item) => item.id === next.id)
       if (idx < 0) return list
       const out = list.slice()
@@ -1362,7 +1362,7 @@ export default function Page() {
     })
 
   const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
-    sync.set("session", (list) => {
+    sync().set("session", (list) => {
       const idx = list.findIndex((item) => item.id === sessionID)
       if (idx < 0) return list
       const out = list.slice()
@@ -1370,7 +1370,7 @@ export default function Page() {
       return out
     })
 
-  const busy = (sessionID: string) => sync.data.session_working(sessionID)
+  const busy = (sessionID: string) => sync().data.session_working(sessionID)
 
   const queuedFollowups = createMemo(() => {
     const id = params.id
@@ -1393,11 +1393,11 @@ export default function Page() {
       setFollowup("failed", input.sessionID, undefined)
 
       const ok = await sendFollowupDraft({
-        client: sdk.client,
-        sync,
-        serverSync,
+        client: sdk().client,
+        sync: sync(),
+        serverSync: serverSync(),
         draft: item,
-        optimisticBusy: item.sessionDirectory === sdk.directory,
+        optimisticBusy: item.sessionDirectory === sdk().directory,
       }).catch((err) => {
         setFollowup("failed", input.sessionID, input.id)
         fail(err)
@@ -1455,7 +1455,7 @@ export default function Page() {
   const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
 
   const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
-    if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
+    if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
     const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
     if (!item) return Promise.resolve()
     if (followupBusy(sessionID)) return Promise.resolve()
@@ -1487,7 +1487,7 @@ export default function Page() {
   }
 
   const halt = (sessionID: string) =>
-    busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
+    busy(sessionID) ? sdk().client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
 
   const revertMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; messageID: string }) => {
@@ -1499,7 +1499,7 @@ export default function Page() {
         prompt.set(value)
       })
       await halt(input.sessionID)
-        .then(() => sdk.client.session.revert(input))
+        .then(() => sdk().client.session.revert(input))
         .then((result) => {
           if (result.data) merge(result.data)
         })
@@ -1532,9 +1532,9 @@ export default function Page() {
       })
 
       const task = !next
-        ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
+        ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
         : halt(sessionID).then(() =>
-            sdk.client.session.revert({
+            sdk().client.session.revert({
               sessionID,
               messageID: next.id,
             }),
@@ -1622,7 +1622,7 @@ export default function Page() {
     visibleUserMessages,
     historyMore,
     historyLoading,
-    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
+    loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
     setPendingMessage: (value) => setUi("pendingMessage", value),

+ 1 - 1
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -57,7 +57,7 @@ export function SessionComposerRegion(props: {
   const view = layout.view(route.sessionKey)
 
   const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
-  const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
+  const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
   const parentID = createMemo(() => info()?.parentID)
   const child = createMemo(() => !!parentID())
   const showComposer = createMemo(() => !props.state.blocked() || child())

+ 8 - 8
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -32,12 +32,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   const permission = usePermission()
 
   const questionRequest = createMemo((): QuestionRequest | undefined => {
-    return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
+    return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
   })
 
   const permissionRequest = createMemo((): PermissionRequest | undefined => {
-    return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
-      return !permission.autoResponds(item, sdk.directory)
+    return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
+      return !permission.autoResponds(item, sdk().directory)
     })
   })
 
@@ -50,14 +50,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   const todos = createMemo((): Todo[] => {
     const id = params.id
     if (!id) return []
-    return serverSync.data.session_todo[id] ?? []
+    return serverSync().data.session_todo[id] ?? []
   })
 
   const done = createMemo(
     () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
   )
 
-  const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
+  const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
 
   const [store, setStore] = createStore({
     responding: undefined as string | undefined,
@@ -78,7 +78,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
     if (store.responding === perm.id) return
 
     setStore("responding", perm.id)
-    sdk.client.permission
+    sdk().client.permission
       .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
       .catch((err: unknown) => {
         const description = err instanceof Error ? err.message : String(err)
@@ -111,8 +111,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   const clear = () => {
     const id = params.id
     if (!id) return
-    serverSync.todo.set(id, [])
-    sync.set("todo", id, [])
+    serverSync().todo.set(id, [])
+    sync().set("todo", id, [])
   }
 
   createEffect(

+ 3 - 3
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -64,7 +64,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   const sdk = useSDK()
   const serverSDK = useServerSDK()
   const language = useLanguage()
-  const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
+  const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
 
   const questions = createMemo(() => props.request.questions)
   const total = createMemo(() => questions().length)
@@ -209,7 +209,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const replyMutation = useMutation(() => ({
-    mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
+    mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
     onMutate: () => {
       props.onSubmit()
     },
@@ -221,7 +221,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }))
 
   const rejectMutation = useMutation(() => ({
-    mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
+    mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
     onMutate: () => {
       props.onSubmit()
     },

+ 28 - 28
packages/app/src/pages/session/message-timeline.tsx

@@ -300,7 +300,7 @@ export function MessageTimeline(props: {
   const sessionMessages = createMemo(() => {
     const id = sessionID()
     if (!id) return emptyMessages
-    return sync.data.message[id] ?? emptyMessages
+    return sync().data.message[id] ?? emptyMessages
   })
   const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
   const assistantMessagesByParent = createMemo(() => {
@@ -324,10 +324,10 @@ export function MessageTimeline(props: {
   const sessionStatus = createMemo(() => {
     const id = sessionID()
     if (!id) return idle
-    return sync.data.session_status[id] ?? idle
+    return sync().data.session_status[id] ?? idle
   })
   const working = createMemo(() => sessionStatus().type !== "idle")
-  const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
+  const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
 
   const [timeoutDone, setTimeoutDone] = createSignal(true)
 
@@ -366,25 +366,25 @@ export function MessageTimeline(props: {
   const info = createMemo(() => {
     const id = sessionID()
     if (!id) return
-    return sync.session.get(id)
+    return sync().session.get(id)
   })
   const titleValue = createMemo(() => info()?.title)
   const titleLabel = createMemo(() => sessionTitle(titleValue()))
   const shareUrl = createMemo(() => info()?.share?.url)
-  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
+  const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
   const parentID = createMemo(() => info()?.parentID)
   const parent = createMemo(() => {
     const id = parentID()
     if (!id) return
-    return sync.session.get(id)
+    return sync().session.get(id)
   })
   const parentMessages = createMemo(() => {
     const id = parentID()
     if (!id) return emptyMessages
-    return sync.data.message[id] ?? emptyMessages
+    return sync().data.message[id] ?? emptyMessages
   })
   const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
-  const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
+  const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
   const childTaskDescription = createMemo(() => {
     const id = sessionID()
     if (!id) return
@@ -730,14 +730,14 @@ export function MessageTimeline(props: {
   }
 
   const shareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
+    mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id, directory: sdk().directory }),
     onError: (err) => {
       console.error("Failed to share session", err)
     },
   }))
 
   const unshareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
+    mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id, directory: sdk().directory }),
     onError: (err) => {
       console.error("Failed to unshare session", err)
     },
@@ -745,9 +745,9 @@ export function MessageTimeline(props: {
 
   const titleMutation = useMutation(() => ({
     mutationFn: (input: { id: string; title: string }) =>
-      sdk.client.session.update({ sessionID: input.id, title: input.title }),
+      sdk().client.session.update({ sessionID: input.id, title: input.title }),
     onSuccess: (_, input) => {
-      sync.set(
+      sync().set(
         produce((draft) => {
           const index = draft.session.findIndex((s) => s.id === input.id)
           if (index !== -1) draft.session[index].title = input.title
@@ -797,8 +797,8 @@ export function MessageTimeline(props: {
       () => [parentID(), childTaskDescription()] as const,
       ([id, description]) => {
         if (!id || description) return
-        if (sync.data.message[id] !== undefined) return
-        void sync.session.sync(id)
+        if (sync().data.message[id] !== undefined) return
+        void sync().session.sync(id)
       },
       { defer: true },
     ),
@@ -846,25 +846,25 @@ export function MessageTimeline(props: {
   }
 
   const archiveSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
+    const session = sync().session.get(sessionID)
     if (!session) return
 
-    const sessions = sync.data.session ?? []
+    const sessions = sync().data.session ?? []
     const index = sessions.findIndex((s) => s.id === sessionID)
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
-    await sdk.client.session
+    await sdk().client.session
       .update({ sessionID, time: { archived: Date.now() } })
       .then(() => {
-        sync.set(
+        sync().set(
           produce((draft) => {
             const index = draft.session.findIndex((s) => s.id === sessionID)
             if (index !== -1) draft.session.splice(index, 1)
           }),
         )
-        sync.session.evict(sessionID)
+        sync().session.evict(sessionID)
         navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-        notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
+        notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
       })
       .catch((err) => {
         showToast({
@@ -875,14 +875,14 @@ export function MessageTimeline(props: {
   }
 
   const deleteSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
+    const session = sync().session.get(sessionID)
     if (!session) return false
 
-    const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
+    const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
     const index = sessions.findIndex((s) => s.id === sessionID)
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
-    const result = await sdk.client.session
+    const result = await sdk().client.session
       .delete({ sessionID })
       .then((x) => x.data)
       .catch((err) => {
@@ -897,7 +897,7 @@ export function MessageTimeline(props: {
 
     const removed = new Set<string>([sessionID])
     const byParent = new Map<string, string[]>()
-    for (const item of sync.data.session) {
+    for (const item of sync().data.session) {
       const parentID = item.parentID
       if (!parentID) continue
       const existing = byParent.get(parentID)
@@ -925,16 +925,16 @@ export function MessageTimeline(props: {
 
     navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
 
-    sync.set(
+    sync().set(
       produce((draft) => {
         draft.session = draft.session.filter((s) => !removed.has(s.id))
       }),
     )
 
     for (const id of removed) {
-      sync.session.evict(id)
+      sync().session.evict(id)
     }
-    notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
+    notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
     return true
   }
 
@@ -946,7 +946,7 @@ export function MessageTimeline(props: {
 
   function DialogDeleteSession(props: { sessionID: string }) {
     const name = createMemo(
-      () => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
+      () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
     )
     const handleDelete = async () => {
       await deleteSession(props.sessionID)

+ 1 - 1
packages/app/src/pages/session/review-tab.tsx

@@ -53,7 +53,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
   const layout = useLayout()
 
   const readFile = async (path: string) => {
-    return sdk.client.file
+    return sdk().client.file
       .read({ path })
       .then((x) => x.data)
       .catch((error) => {

+ 1 - 1
packages/app/src/pages/session/usage-exceeded-dialogs.tsx

@@ -51,7 +51,7 @@ export function useUsageExceededDialogs() {
   )
 
   onCleanup(
-    sdk.event.on("session.status", (evt) => {
+    sdk().event.on("session.status", (evt) => {
       if (evt.properties.sessionID !== params.id) return
       if (evt.properties.status.type !== "retry") return
       const { action } = evt.properties.status

+ 19 - 19
packages/app/src/pages/session/use-session-commands.tsx

@@ -52,7 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const info = () => {
     const id = params.id
     if (!id) return
-    return sync.session.get(id)
+    return sync().session.get(id)
   }
   const hasReview = () => !!params.id
   const normalizeTab = (tab: string) => {
@@ -73,7 +73,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const messages = () => {
     const id = params.id
     if (!id) return []
-    return sync.data.message[id] ?? []
+    return sync().data.message[id] ?? []
   }
   const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
   const visibleUserMessages = () => {
@@ -122,8 +122,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
 
   const isAutoAcceptActive = () => {
     const sessionID = params.id
-    if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
-    return permission.isAutoAcceptingDirectory(sdk.directory)
+    if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
+    return permission.isAutoAcceptingDirectory(sdk().directory)
   }
   const write = async (value: string) => {
     const body = typeof document === "undefined" ? undefined : document.body
@@ -175,7 +175,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
       return
     }
 
-    const url = await sdk.client.session
+    const url = await sdk().client.session
       .share({ sessionID })
       .then((res) => res.data?.share?.url)
       .catch(() => undefined)
@@ -195,7 +195,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     const sessionID = params.id
     if (!sessionID) return
 
-    await sdk.client.session
+    await sdk().client.session
       .unshare({ sessionID })
       .then(() =>
         showToast({
@@ -263,12 +263,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
 
   const toggleAutoAccept = () => {
     const sessionID = params.id
-    if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
-    else permission.toggleAutoAcceptDirectory(sdk.directory)
+    if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
+    else permission.toggleAutoAcceptDirectory(sdk().directory)
 
     const active = sessionID
-      ? permission.isAutoAccepting(sessionID, sdk.directory)
-      : permission.isAutoAcceptingDirectory(sdk.directory)
+      ? permission.isAutoAccepting(sessionID, sdk().directory)
+      : permission.isAutoAcceptingDirectory(sdk().directory)
     showToast({
       title: active
         ? language.t("toast.permissions.autoaccept.on.title")
@@ -283,18 +283,18 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     const sessionID = params.id
     if (!sessionID) return
 
-    if (sync.data.session_working(params.id ?? "")) {
-      await sdk.client.session.abort({ sessionID }).catch(() => {})
+    if (sync().data.session_working(params.id ?? "")) {
+      await sdk().client.session.abort({ sessionID }).catch(() => {})
     }
 
     const revert = info()?.revert?.messageID
     const message = findLast(userMessages(), (x) => !revert || x.id < revert)
     if (!message) return
 
-    await sdk.client.session.revert({ sessionID, messageID: message.id })
-    const parts = sync.data.part[message.id]
+    await sdk().client.session.revert({ sessionID, messageID: message.id })
+    const parts = sync().data.part[message.id]
     if (parts) {
-      const restored = extractPromptFromParts(parts, { directory: sdk.directory })
+      const restored = extractPromptFromParts(parts, { directory: sdk().directory })
       prompt.set(restored)
     }
 
@@ -311,14 +311,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
 
     const next = userMessages().find((x) => x.id > revertMessageID)
     if (!next) {
-      await sdk.client.session.unrevert({ sessionID })
+      await sdk().client.session.unrevert({ sessionID })
       prompt.reset()
       const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
       setActiveMessage(last)
       return
     }
 
-    await sdk.client.session.revert({ sessionID, messageID: next.id })
+    await sdk().client.session.revert({ sessionID, messageID: next.id })
     const prev = findLast(userMessages(), (x) => x.id < next.id)
     setActiveMessage(prev)
   }
@@ -336,7 +336,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
       return
     }
 
-    await sdk.client.session.summarize({
+    await sdk().client.session.summarize({
       sessionID,
       modelID: model.id,
       providerID: model.provider.id,
@@ -350,7 +350,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   }
 
   const shareCmds = () => {
-    if (sync.data.config.share === "disabled") return []
+    if (sync().data.config.share === "disabled") return []
     return [
       sessionCommand({
         id: "session.share",