Просмотр исходного кода

app: refactor server management backend (#13813)

Brendan Allan 1 месяц назад
Родитель
Сommit
1bb8574179

+ 9 - 0
.zed/settings.json

@@ -0,0 +1,9 @@
+{
+  "format_on_save": "on",
+  "formatter": {
+    "external": {
+      "command": "bunx",
+      "arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
+    }
+  }
+}

+ 35 - 62
packages/app/src/app.tsx

@@ -1,35 +1,36 @@
 import "@/index.css"
-import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
-import { Router, Route, Navigate } from "@solidjs/router"
-import { MetaProvider } from "@solidjs/meta"
-import { Font } from "@opencode-ai/ui/font"
-import { MarkedProvider } from "@opencode-ai/ui/context/marked"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { Code } from "@opencode-ai/ui/code"
 import { I18nProvider } from "@opencode-ai/ui/context"
+import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
 import { Diff } from "@opencode-ai/ui/diff"
-import { Code } from "@opencode-ai/ui/code"
+import { Font } from "@opencode-ai/ui/font"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
+import { MetaProvider } from "@solidjs/meta"
+import { Navigate, Route, Router } from "@solidjs/router"
+import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
+import { CommandProvider } from "@/context/command"
+import { CommentsProvider } from "@/context/comments"
+import { FileProvider } from "@/context/file"
+import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSyncProvider } from "@/context/global-sync"
-import { PermissionProvider } from "@/context/permission"
+import { HighlightsProvider } from "@/context/highlights"
+import { LanguageProvider, useLanguage } from "@/context/language"
 import { LayoutProvider } from "@/context/layout"
-import { GlobalSDKProvider } from "@/context/global-sdk"
-import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
-import { SettingsProvider } from "@/context/settings"
-import { TerminalProvider } from "@/context/terminal"
-import { PromptProvider } from "@/context/prompt"
-import { FileProvider } from "@/context/file"
-import { CommentsProvider } from "@/context/comments"
-import { NotificationProvider } from "@/context/notification"
 import { ModelsProvider } from "@/context/models"
-import { DialogProvider } from "@opencode-ai/ui/context/dialog"
-import { CommandProvider } from "@/context/command"
-import { LanguageProvider, useLanguage } from "@/context/language"
+import { NotificationProvider } from "@/context/notification"
+import { PermissionProvider } from "@/context/permission"
 import { usePlatform } from "@/context/platform"
-import { HighlightsProvider } from "@/context/highlights"
-import Layout from "@/pages/layout"
+import { PromptProvider } from "@/context/prompt"
+import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
+import { SettingsProvider } from "@/context/settings"
+import { TerminalProvider } from "@/context/terminal"
 import DirectoryLayout from "@/pages/directory-layout"
+import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
+
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
 const Loading = () => <div class="size-full" />
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
 
 declare global {
   interface Window {
-    __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
+    __OPENCODE__?: {
+      updaterEnabled?: boolean
+      deepLinks?: string[]
+      wsl?: boolean
+    }
   }
 }
 
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   )
 }
 
-const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
-  if (platform.platform !== "web") return
-  const result = platform.getDefaultServerUrl?.()
-  if (result instanceof Promise) return
-  if (!result) return
-  return normalizeServerUrl(result)
-}
-
-const resolveDefaultServerUrl = (props: {
-  defaultUrl?: string
-  storedDefaultServerUrl?: string
-  hostname: string
-  origin: string
-  isDev: boolean
-  devHost?: string
-  devPort?: string
-}) => {
-  if (props.defaultUrl) return props.defaultUrl
-  if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
-  if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
-  if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
-  return props.origin
-}
-
 export function AppBaseProviders(props: ParentProps) {
   return (
     <MetaProvider>
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
 function ServerKey(props: ParentProps) {
   const server = useServer()
   return (
-    <Show when={server.url} keyed>
+    <Show when={server.key} keyed>
       {props.children}
     </Show>
   )
 }
 
-export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
-  const platform = usePlatform()
-  const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
-  const defaultServerUrl = resolveDefaultServerUrl({
-    defaultUrl: props.defaultUrl,
-    storedDefaultServerUrl,
-    hostname: location.hostname,
-    origin: window.location.origin,
-    isDev: import.meta.env.DEV,
-    devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
-    devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
-  })
-
+export function AppInterface(props: {
+  children?: JSX.Element
+  defaultServer: ServerConnection.Key
+  servers?: Array<ServerConnection.Any>
+}) {
   return (
-    <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
+    <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
       <ServerKey>
         <GlobalSDKProvider>
           <GlobalSyncProvider>

+ 93 - 87
packages/app/src/components/dialog-select-server.tsx

@@ -1,19 +1,18 @@
-import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
-import { createStore, reconcile } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { Button } from "@opencode-ai/ui/button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+import { List } from "@opencode-ai/ui/list"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { normalizeServerUrl, useServer } from "@/context/server"
-import { usePlatform } from "@/context/platform"
-import { useNavigate } from "@solidjs/router"
-import { useLanguage } from "@/context/language"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { useGlobalSDK } from "@/context/global-sdk"
 import { showToast } from "@opencode-ai/ui/toast"
+import { useNavigate } from "@solidjs/router"
+import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
 import { ServerRow } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
 interface AddRowProps {
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
     if (!looksComplete(value)) return
     const normalized = normalizeServerUrl(value)
     if (!normalized) return
-    const result = await checkServerHealth(normalized, fetcher)
+    const result = await checkServerHealth({ url: normalized }, fetcher)
     setStatus(result.healthy)
   }
 
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
   const dialog = useDialog()
   const server = useServer()
   const platform = usePlatform()
-  const globalSDK = useGlobalSDK()
   const language = useLanguage()
   const fetcher = platform.fetch ?? globalThis.fetch
   const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
   const { previewStatus } = useServerPreview(fetcher)
   let listRoot: HTMLDivElement | undefined
   const [store, setStore] = createStore({
-    status: {} as Record<string, ServerHealth | undefined>,
+    status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
     addServer: {
       url: "",
       adding: false,
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
     })
   }
 
-  const replaceServer = (original: string, next: string) => {
-    const active = server.url
-    const nextActive = active === original ? next : active
+  const replaceServer = (original: ServerConnection.Http, next: string) => {
+    const active = server.key
+    const newConn = server.add(next)
+    if (!newConn) return
 
-    server.add(next)
+    const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
     if (nextActive) server.setActive(nextActive)
-    server.remove(original)
+    server.remove(ServerConnection.key(original))
   }
 
   const items = createMemo(() => {
-    const current = server.url
+    const current = server.current
     const list = server.list
     if (!current) return list
     if (!list.includes(current)) return [current, ...list]
     return [current, ...list.filter((x) => x !== current)]
   })
 
-  const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
+  const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
 
   const sortedItems = createMemo(() => {
     const list = items()
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
     return list.slice().sort((a, b) => {
       if (a === active) return -1
       if (b === active) return 1
-      const diff = rank(store.status[a]) - rank(store.status[b])
+      const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
       if (diff !== 0) return diff
       return (order.get(a) ?? 0) - (order.get(b) ?? 0)
     })
   })
 
   async function refreshHealth() {
-    const results: Record<string, ServerHealth> = {}
+    const results: Record<ServerConnection.Key, ServerHealth> = {}
     await Promise.all(
-      items().map(async (url) => {
-        results[url] = await checkServerHealth(url, fetcher)
+      items().map(async (conn) => {
+        results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
       }),
     )
     setStore("status", reconcile(results))
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
     onCleanup(() => clearInterval(interval))
   })
 
-  async function select(value: string, persist?: boolean) {
-    if (!persist && store.status[value]?.healthy === false) return
+  async function select(conn: ServerConnection.Any, persist?: boolean) {
+    if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
     dialog.close()
     if (persist) {
-      server.add(value)
+      server.add(conn.http.url)
       navigate("/")
       return
     }
-    server.setActive(value)
+    server.setActive(ServerConnection.key(conn))
     navigate("/")
   }
 
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
 
     setStore("addServer", { adding: true, error: "" })
 
-    const result = await checkServerHealth(normalized, fetcher)
+    const result = await checkServerHealth({ url: normalized }, fetcher)
     setStore("addServer", { adding: false })
 
     if (!result.healthy) {
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
     }
 
     resetAdd()
-    await select(normalized, true)
+    await select({ type: "http", http: { url: normalized } }, true)
   }
 
-  async function handleEdit(original: string, value: string) {
-    if (store.editServer.busy) return
+  async function handleEdit(original: ServerConnection.Any, value: string) {
+    if (store.editServer.busy || original.type !== "http") return
     const normalized = normalizeServerUrl(value)
     if (!normalized) {
       resetEdit()
       return
     }
 
-    if (normalized === original) {
+    if (normalized === original.http.url) {
       resetEdit()
       return
     }
 
     setStore("editServer", { busy: true, error: "" })
 
-    const result = await checkServerHealth(normalized, fetcher)
+    const result = await checkServerHealth({ url: normalized }, fetcher)
     setStore("editServer", { busy: false })
 
     if (!result.healthy) {
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
     handleAdd(store.addServer.url)
   }
 
-  const handleEditKey = (event: KeyboardEvent, original: string) => {
+  const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
     event.stopPropagation()
     if (event.key === "Escape") {
       event.preventDefault()
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
     handleEdit(original, store.editServer.value)
   }
 
-  async function handleRemove(url: string) {
+  async function handleRemove(url: ServerConnection.Key) {
     server.remove(url)
     if ((await platform.getDefaultServerUrl?.()) === url) {
       platform.setDefaultServerUrl?.(null)
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
       <div class="flex flex-col gap-2">
         <div ref={(el) => (listRoot = el)}>
           <List
-            search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
+            search={{
+              placeholder: language.t("dialog.server.search.placeholder"),
+              autofocus: false,
+            }}
             noInitialSelection
             emptyMessage={language.t("dialog.server.empty")}
             items={sortedItems}
-            key={(x) => x}
+            key={(x) => x.http.url}
             onSelect={(x) => {
               if (x) select(x)
             }}
@@ -428,7 +430,7 @@ export function DialogSelectServer() {
               return (
                 <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
                   <Show
-                    when={store.editServer.id !== i}
+                    when={store.editServer.id !== i.http.url}
                     fallback={
                       <EditRow
                         value={store.editServer.value}
@@ -443,12 +445,12 @@ export function DialogSelectServer() {
                     }
                   >
                     <ServerRow
-                      url={i}
-                      status={store.status[i]}
-                      dimmed={store.status[i]?.healthy === false}
+                      conn={i}
+                      status={store.status[ServerConnection.key(i)]}
+                      dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
                       class="flex items-center gap-3 px-4 min-w-0 flex-1"
                       badge={
-                        <Show when={defaultUrl() === i}>
+                        <Show when={defaultUrl() === i.http.url}>
                           <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
                             {language.t("dialog.server.status.default")}
                           </span>
@@ -456,59 +458,63 @@ export function DialogSelectServer() {
                       }
                     />
                   </Show>
-                  <Show when={store.editServer.id !== i}>
+                  <Show when={store.editServer.id !== i.http.url}>
                     <div class="flex items-center justify-center gap-5 pl-4">
                       <Show when={current() === i}>
                         <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
                       </Show>
 
-                      <DropdownMenu>
-                        <DropdownMenu.Trigger
-                          as={IconButton}
-                          icon="dot-grid"
-                          variant="ghost"
-                          class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
-                          onClick={(e: MouseEvent) => e.stopPropagation()}
-                          onPointerDown={(e: PointerEvent) => e.stopPropagation()}
-                        />
-                        <DropdownMenu.Portal>
-                          <DropdownMenu.Content class="mt-1">
-                            <DropdownMenu.Item
-                              onSelect={() => {
-                                setStore("editServer", {
-                                  id: i,
-                                  value: i,
-                                  error: "",
-                                  status: store.status[i]?.healthy,
-                                })
-                              }}
-                            >
-                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <Show when={canDefault() && defaultUrl() !== i}>
-                              <DropdownMenu.Item onSelect={() => setDefault(i)}>
-                                <DropdownMenu.ItemLabel>
-                                  {language.t("dialog.server.menu.default")}
-                                </DropdownMenu.ItemLabel>
+                      <Show when={i.type === "http"}>
+                        <DropdownMenu>
+                          <DropdownMenu.Trigger
+                            as={IconButton}
+                            icon="dot-grid"
+                            variant="ghost"
+                            class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
+                            onClick={(e: MouseEvent) => e.stopPropagation()}
+                            onPointerDown={(e: PointerEvent) => e.stopPropagation()}
+                          />
+                          <DropdownMenu.Portal>
+                            <DropdownMenu.Content class="mt-1">
+                              <DropdownMenu.Item
+                                onSelect={() => {
+                                  setStore("editServer", {
+                                    id: i.http.url,
+                                    value: i.http.url,
+                                    error: "",
+                                    status: store.status[ServerConnection.key(i)]?.healthy,
+                                  })
+                                }}
+                              >
+                                <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
-                            </Show>
-                            <Show when={canDefault() && defaultUrl() === i}>
-                              <DropdownMenu.Item onSelect={() => setDefault(null)}>
+                              <Show when={canDefault() && defaultUrl() !== i.http.url}>
+                                <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
+                                  <DropdownMenu.ItemLabel>
+                                    {language.t("dialog.server.menu.default")}
+                                  </DropdownMenu.ItemLabel>
+                                </DropdownMenu.Item>
+                              </Show>
+                              <Show when={canDefault() && defaultUrl() === i.http.url}>
+                                <DropdownMenu.Item onSelect={() => setDefault(null)}>
+                                  <DropdownMenu.ItemLabel>
+                                    {language.t("dialog.server.menu.defaultRemove")}
+                                  </DropdownMenu.ItemLabel>
+                                </DropdownMenu.Item>
+                              </Show>
+                              <DropdownMenu.Separator />
+                              <DropdownMenu.Item
+                                onSelect={() => handleRemove(ServerConnection.key(i))}
+                                class="text-text-on-critical-base hover:bg-surface-critical-weak"
+                              >
                                 <DropdownMenu.ItemLabel>
-                                  {language.t("dialog.server.menu.defaultRemove")}
+                                  {language.t("dialog.server.menu.delete")}
                                 </DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
-                            </Show>
-                            <DropdownMenu.Separator />
-                            <DropdownMenu.Item
-                              onSelect={() => handleRemove(i)}
-                              class="text-text-on-critical-base hover:bg-surface-critical-weak"
-                            >
-                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                          </DropdownMenu.Content>
-                        </DropdownMenu.Portal>
-                      </DropdownMenu>
+                            </DropdownMenu.Content>
+                          </DropdownMenu.Portal>
+                        </DropdownMenu>
+                      </Show>
                     </div>
                   </Show>
                 </div>

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

@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 
-const clientFor = (directory: string) => ({
-  session: {
-    create: async () => {
-      createdSessions.push(directory)
-      return { data: { id: `session-${createdSessions.length}` } }
+const clientFor = (directory: string) => {
+  createdClients.push(directory)
+  return {
+    session: {
+      create: async () => {
+        createdSessions.push(directory)
+        return { data: { id: `session-${createdSessions.length}` } }
+      },
+      shell: async () => {
+        sentShell.push(directory)
+        return { data: undefined }
+      },
+      prompt: async () => ({ data: undefined }),
+      command: async () => ({ data: undefined }),
+      abort: async () => ({ data: undefined }),
     },
-    shell: async () => {
-      sentShell.push(directory)
-      return { data: undefined }
+    worktree: {
+      create: async () => ({ data: { directory: `${directory}/new` } }),
     },
-    prompt: async () => ({ data: undefined }),
-    command: async () => ({ data: undefined }),
-    abort: async () => ({ data: undefined }),
-  },
-  worktree: {
-    create: async () => ({ data: { directory: `${directory}/new` } }),
-  },
-})
+  }
+}
 
 beforeAll(async () => {
   const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
   }))
 
   mock.module("@/context/sdk", () => ({
-    useSDK: () => ({
-      directory: "/repo/main",
-      client: rootClient,
-      url: "http://localhost:4096",
-    }),
+    useSDK: () => {
+      const sdk = {
+        directory: "/repo/main",
+        client: rootClient,
+        url: "http://localhost:4096",
+        createClient(opts: any) {
+          return clientFor(opts.directory)
+        },
+      }
+      return sdk
+    },
   }))
 
   mock.module("@/context/sync", () => ({

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

@@ -1,21 +1,20 @@
-import { Accessor } from "solid-js"
-import { useNavigate, useParams } from "@solidjs/router"
-import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
+import type { Message } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
-import { useLocal } from "@/context/local"
-import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
+import { useNavigate, useParams } from "@solidjs/router"
+import type { Accessor } from "solid-js"
+import type { FileSelection } from "@/context/file"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
-import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
-import { useLanguage } from "@/context/language"
 import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
-import type { FileSelection } from "@/context/file"
-import { setCursorPosition } from "./editor-dom"
 import { buildRequestParts } from "./build-request-parts"
+import { setCursorPosition } from "./editor-dom"
 
 type PendingPrompt = {
   abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const sdk = useSDK()
   const sync = useSync()
   const globalSync = useGlobalSync()
-  const platform = usePlatform()
   const local = useLocal()
   const prompt = usePrompt()
   const layout = useLayout()
@@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
 
       if (sessionDirectory !== projectDirectory) {
-        client = createOpencodeClient({
-          baseUrl: sdk.url,
-          fetch: platform.fetch,
+        client = sdk.createClient({
           directory: sessionDirectory,
           throwOnError: true,
         })
@@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       const timer = { id: undefined as number | undefined }
       const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
         timer.id = window.setTimeout(() => {
-          resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+          resolve({
+            status: "failed",
+            message: language.t("workspace.error.stillPreparing"),
+          })
         }, timeoutMs)
       })
 

+ 14 - 5
packages/app/src/components/server/server-row.tsx

@@ -1,10 +1,19 @@
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
-import { serverDisplayName } from "@/context/server"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  type JSXElement,
+  onCleanup,
+  onMount,
+  type ParentProps,
+  Show,
+} from "solid-js"
+import { type ServerConnection, serverDisplayName } from "@/context/server"
 import type { ServerHealth } from "@/utils/server-health"
 
 interface ServerRowProps extends ParentProps {
-  url: string
+  conn: ServerConnection.Any
   status?: ServerHealth
   class?: string
   nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
   const [truncated, setTruncated] = createSignal(false)
   let nameRef: HTMLSpanElement | undefined
   let versionRef: HTMLSpanElement | undefined
-  const name = createMemo(() => serverDisplayName(props.url))
+  const name = createMemo(() => serverDisplayName(props.conn))
 
   const check = () => {
     const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
 
   createEffect(() => {
     name()
-    props.url
+    props.conn.http.url
     props.status?.version
     queueMicrotask(check)
   })

+ 44 - 36
packages/app/src/components/status-popover.tsx

@@ -1,21 +1,21 @@
-import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
-import { createStore, reconcile } from "solid-js/store"
-import { useNavigate } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
 import { Popover } from "@opencode-ai/ui/popover"
-import { Tabs } from "@opencode-ai/ui/tabs"
-import { Button } from "@opencode-ai/ui/button"
 import { Switch } from "@opencode-ai/ui/switch"
-import { Icon } from "@opencode-ai/ui/icon"
+import { Tabs } from "@opencode-ai/ui/tabs"
 import { showToast } from "@opencode-ai/ui/toast"
-import { useSync } from "@/context/sync"
-import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, useServer } from "@/context/server"
-import { usePlatform } from "@/context/platform"
-import { useLanguage } from "@/context/language"
-import { DialogSelectServer } from "./dialog-select-server"
+import { useNavigate } from "@solidjs/router"
+import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
 import { ServerRow } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useSDK } from "@/context/sdk"
+import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { useSync } from "@/context/sync"
 import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
+import { DialogSelectServer } from "./dialog-select-server"
 
 const pollMs = 10_000
 
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
 }
 
 const listServersByHealth = (
-  list: string[],
-  active: string | undefined,
-  status: Record<string, ServerHealth | undefined>,
+  list: ServerConnection.Any[],
+  active: ServerConnection.Key | undefined,
+  status: Record<ServerConnection.Key, ServerHealth | undefined>,
 ) => {
   if (!list.length) return list
   const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
   }
 
   return list.slice().sort((a, b) => {
-    if (a === active) return -1
-    if (b === active) return 1
-    const diff = rank(status[a]) - rank(status[b])
+    if (ServerConnection.key(a) === active) return -1
+    if (ServerConnection.key(b) === active) return 1
+    const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
     if (diff !== 0) return diff
     return (order.get(a) ?? 0) - (order.get(b) ?? 0)
   })
 }
 
-const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
-  const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
+  const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
   createEffect(() => {
     const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
     const refresh = async () => {
       const results: Record<string, ServerHealth> = {}
       await Promise.all(
-        list.map(async (url) => {
-          results[url] = await checkServerHealth(url, fetcher)
+        list.map(async (conn) => {
+          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
         }),
       )
       if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
   return status
 }
 
-const useDefaultServerUrl = (
+const useDefaultServerKey = (
   get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
 ) => {
   const [url, setUrl] = createSignal<string | undefined>()
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
     })
   })
 
-  return { url, refresh: () => setTick((value) => value + 1) }
+  return {
+    key: () => {
+      const u = url()
+      if (!u) return
+      return ServerConnection.key({ type: "http", http: { url: u } })
+    },
+    refresh: () => setTick((value) => value + 1),
+  }
 }
 
 const useMcpToggle = (input: {
@@ -163,16 +170,16 @@ export function StatusPopover() {
 
   const fetcher = platform.fetch ?? globalThis.fetch
   const servers = createMemo(() => {
-    const current = server.url
+    const current = server.current
     const list = server.list
     if (!current) return list
-    if (!list.includes(current)) return [current, ...list]
-    return [current, ...list.filter((item) => item !== current)]
+    if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
+    return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
   })
   const health = useServerHealth(servers, fetcher)
-  const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
+  const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const mcp = useMcpToggle({ sync, sdk, language })
-  const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
+  const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
   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)
@@ -251,8 +258,9 @@ export function StatusPopover() {
             <div class="flex flex-col px-2 pb-2">
               <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
                 <For each={sortedServers()}>
-                  {(url) => {
-                    const isBlocked = () => health[url]?.healthy === false
+                  {(s) => {
+                    const key = ServerConnection.key(s)
+                    const isBlocked = () => health[key]?.healthy === false
                     return (
                       <button
                         type="button"
@@ -264,19 +272,19 @@ export function StatusPopover() {
                         aria-disabled={isBlocked()}
                         onClick={() => {
                           if (isBlocked()) return
-                          server.setActive(url)
+                          server.setActive(key)
                           navigate("/")
                         }}
                       >
                         <ServerRow
-                          url={url}
-                          status={health[url]}
+                          conn={s}
+                          status={health[key]}
                           dimmed={isBlocked()}
                           class="flex items-center gap-2 w-full min-w-0"
                           nameClass="text-14-regular text-text-base truncate"
                           versionClass="text-12-regular text-text-weak truncate"
                           badge={
-                            <Show when={url === defaultServer.url()}>
+                            <Show when={key === defaultServer.key()}>
                               <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
                                 {language.t("common.default")}
                               </span>
@@ -284,7 +292,7 @@ export function StatusPopover() {
                           }
                         >
                           <div class="flex-1" />
-                          <Show when={url === server.url}>
+                          <Show when={server.current && key === ServerConnection.key(server.current)}>
                             <Icon name="check" size="small" class="text-icon-weak shrink-0" />
                           </Show>
                         </ServerRow>

+ 27 - 15
packages/app/src/components/terminal.tsx

@@ -1,14 +1,15 @@
-import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
-import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
+import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { SerializeAddon } from "@/addons/serialize"
+import { matchKeybind, parseKeybind } from "@/context/command"
+import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
+import { useServer } from "@/context/server"
 import { monoFontFamily, useSettings } from "@/context/settings"
-import { parseKeybind, matchKeybind } from "@/context/command"
-import { SerializeAddon } from "@/addons/serialize"
-import { LocalPTY } from "@/context/terminal"
-import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
-import { useLanguage } from "@/context/language"
-import { showToast } from "@opencode-ai/ui/toast"
+import type { LocalPTY } from "@/context/terminal"
 import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
 import { terminalWriter } from "@/utils/terminal-writer"
 
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
   input.container.addEventListener("pointerdown", input.handlePointerDown)
   input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
 
-  input.container.addEventListener("click", input.handleLinkClick, { capture: true })
-  input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
+  input.container.addEventListener("click", input.handleLinkClick, {
+    capture: true,
+  })
+  input.cleanups.push(() =>
+    input.container.removeEventListener("click", input.handleLinkClick, {
+      capture: true,
+    }),
+  )
 
   input.term.textarea?.addEventListener("focus", handleTextareaFocus)
   input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
   const settings = useSettings()
   const theme = useTheme()
   const language = useLanguage()
+  const server = useServer()
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
   let ws: WebSocket | undefined
@@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
       serializeAddon = serializer
 
       t.open(container)
-      useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
+      useTerminalUiBindings({
+        container,
+        term: t,
+        cleanups,
+        handlePointerDown,
+        handleLinkClick,
+      })
 
       focusTerminal()
 
@@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
       url.searchParams.set("directory", sdk.directory)
       url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
       url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
-      if (window.__OPENCODE__?.serverPassword) {
-        url.username = "opencode"
-        url.password = window.__OPENCODE__?.serverPassword
-      }
+      url.username = server.current?.http.username ?? ""
+      url.password = server.current?.http.password ?? ""
       const socket = new WebSocket(url)
       socket.binaryType = "arraybuffer"
       ws = socket

+ 27 - 21
packages/app/src/context/global-sdk.tsx

@@ -1,8 +1,9 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
+import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { batch, onCleanup } from "solid-js"
 import z from "zod"
+import { createSdkForServer } from "@/utils/server"
 import { usePlatform } from "./platform"
 import { useServer } from "./server"
 
@@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     const platform = usePlatform()
     const abort = new AbortController()
 
-    const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
-
-    const auth = (() => {
-      if (!password) return
-      if (!server.isLocal()) return
-      return {
-        Authorization: `Basic ${btoa(`opencode:${password}`)}`,
-      }
-    })()
-
     const eventFetch = (() => {
-      if (!platform.fetch) return
+      if (!platform.fetch || !server.current) return
       try {
-        const url = new URL(server.url)
+        const url = new URL(server.current.http.url)
         const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
         if (url.protocol === "http:" && !loopback) return platform.fetch
       } catch {
@@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       }
     })()
 
-    const eventSdk = createOpencodeClient({
-      baseUrl: server.url,
+    const currentServer = server.current
+    if (!currentServer) throw new Error("No server available")
+
+    const eventSdk = createSdkForServer({
       signal: abort.signal,
       fetch: eventFetch,
-      headers: eventFetch ? undefined : auth,
+      server: currentServer.http,
     })
     const emitter = createGlobalEmitter<{
       [key: string]: Event
@@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
               if (streamErrorLogged) return
               streamErrorLogged = true
               console.error("[global-sdk] event stream error", {
-                url: server.url,
+                url: currentServer.http.url,
                 fetch: eventFetch ? "platform" : "webview",
                 error,
               })
@@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
           if (!aborted(error) && !streamErrorLogged) {
             streamErrorLogged = true
             console.error("[global-sdk] event stream failed", {
-              url: server.url,
+              url: currentServer.http.url,
               fetch: eventFetch ? "platform" : "webview",
               error,
             })
@@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       flush()
     })
 
-    const sdk = createOpencodeClient({
-      baseUrl: server.url,
+    const sdk = createSdkForServer({
+      server: server.current.http,
       fetch: platform.fetch,
       throwOnError: true,
     })
 
-    return { url: server.url, client: sdk, event: emitter }
+    return {
+      url: currentServer.http.url,
+      client: sdk,
+      event: emitter,
+      createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
+        const s = server.current
+        if (!s) throw new Error("Server not available")
+        return createSdkForServer({
+          server: s.http,
+          fetch: platform.fetch,
+          ...opts,
+        })
+      },
+    }
   },
 })

+ 40 - 30
packages/app/src/context/global-sync.tsx

@@ -1,41 +1,41 @@
-import {
-  type Config,
-  type Path,
-  type Project,
-  type ProviderAuthResponse,
-  type ProviderListResponse,
-  type Todo,
-  createOpencodeClient,
+import type {
+  Config,
+  OpencodeClient,
+  Path,
+  Project,
+  ProviderAuthResponse,
+  ProviderListResponse,
+  Todo,
 } from "@opencode-ai/sdk/v2/client"
-import { createStore, produce, reconcile } from "solid-js/store"
-import { useGlobalSDK } from "./global-sdk"
-import type { InitError } from "../pages/error"
+import { showToast } from "@opencode-ai/ui/toast"
+import { getFilename } from "@opencode-ai/util/path"
 import {
   createContext,
   createEffect,
-  untrack,
   getOwner,
-  useContext,
+  Match,
   onCleanup,
   onMount,
   type ParentProps,
   Switch,
-  Match,
+  untrack,
+  useContext,
 } from "solid-js"
-import { showToast } from "@opencode-ai/ui/toast"
-import { getFilename } from "@opencode-ai/util/path"
-import { usePlatform } from "./platform"
+import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
-import { createRefreshQueue } from "./global-sync/queue"
+import type { InitError } from "../pages/error"
+import { useGlobalSDK } from "./global-sdk"
+import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
-import { trimSessions } from "./global-sync/session-trim"
-import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
-import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
-import { sanitizeProject } from "./global-sync/utils"
+import { createRefreshQueue } from "./global-sync/queue"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
+import { sanitizeProject } from "./global-sync/utils"
+import { usePlatform } from "./platform"
 
 type GlobalStore = {
   ready: boolean
@@ -77,7 +77,7 @@ function createGlobalSync() {
     loadSessionsFallback: 0,
   }
 
-  const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
+  const sdkCache = new Map<string, OpencodeClient>()
   const booting = new Map<string, Promise<void>>()
   const sessionLoads = new Map<string, Promise<void>>()
   const sessionMeta = new Map<string, { limit: number }>()
@@ -151,9 +151,7 @@ function createGlobalSync() {
   const sdkFor = (directory: string) => {
     const cached = sdkCache.get(directory)
     if (cached) return cached
-    const sdk = createOpencodeClient({
-      baseUrl: globalSDK.url,
-      fetch: platform.fetch,
+    const sdk = globalSDK.createClient({
       directory,
       throwOnError: true,
     })
@@ -193,7 +191,10 @@ function createGlobalSync() {
     const [store, setStore] = children.child(directory, { bootstrap: false })
     const meta = sessionMeta.get(directory)
     if (meta && meta.limit >= store.limit) {
-      const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
+      const next = trimSessions(store.session, {
+        limit: store.limit,
+        permission: store.permission,
+      })
       if (next.length !== store.session.length) {
         setStore("session", reconcile(next, { key: "id" }))
       }
@@ -218,10 +219,17 @@ function createGlobalSync() {
           .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
         const limit = store.limit
         const childSessions = store.session.filter((s) => !!s.parentID)
-        const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
+        const sessions = trimSessions([...nonArchived, ...childSessions], {
+          limit,
+          permission: store.permission,
+        })
         setStore(
           "sessionTotal",
-          estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
+          estimateRootSessionTotal({
+            count: nonArchived.length,
+            limit: x.limit,
+            limited: x.limited,
+          }),
         )
         setStore("session", reconcile(sessions, { key: "id" }))
         sessionMeta.set(directory, { limit })
@@ -331,7 +339,9 @@ function createGlobalSync() {
     await bootstrapGlobal({
       globalSDK: globalSDK.client,
       connectErrorTitle: language.t("dialog.server.add.error"),
-      connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
+      connectErrorDescription: language.t("error.globalSync.connectFailed", {
+        url: globalSDK.url,
+      }),
       requestFailedTitle: language.t("common.requestFailed"),
       setGlobalStore,
     })

+ 16 - 16
packages/app/src/context/global-sync/bootstrap.ts

@@ -1,21 +1,21 @@
-import {
-  type Config,
-  type Path,
-  type PermissionRequest,
-  type Project,
-  type ProviderAuthResponse,
-  type ProviderListResponse,
-  type QuestionRequest,
-  type Todo,
-  createOpencodeClient,
+import type {
+  Config,
+  OpencodeClient,
+  Path,
+  PermissionRequest,
+  Project,
+  ProviderAuthResponse,
+  ProviderListResponse,
+  QuestionRequest,
+  Todo,
 } from "@opencode-ai/sdk/v2/client"
+import { showToast } from "@opencode-ai/ui/toast"
+import { getFilename } from "@opencode-ai/util/path"
+import { retry } from "@opencode-ai/util/retry"
 import { batch } from "solid-js"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
-import { retry } from "@opencode-ai/util/retry"
-import { getFilename } from "@opencode-ai/util/path"
-import { showToast } from "@opencode-ai/ui/toast"
-import { cmp, normalizeProviderList } from "./utils"
 import type { State, VcsCache } from "./types"
+import { cmp, normalizeProviderList } from "./utils"
 
 type GlobalStore = {
   ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
 }
 
 export async function bootstrapGlobal(input: {
-  globalSDK: ReturnType<typeof createOpencodeClient>
+  globalSDK: OpencodeClient
   connectErrorTitle: string
   connectErrorDescription: string
   requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
 
 export async function bootstrapDirectory(input: {
   directory: string
-  sdk: ReturnType<typeof createOpencodeClient>
+  sdk: OpencodeClient
   store: Store<State>
   setStore: SetStoreFunction<State>
   vcsCache: VcsCache

+ 6 - 7
packages/app/src/context/sdk.tsx

@@ -1,9 +1,8 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
+import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
+import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
 import { useGlobalSDK } from "./global-sdk"
-import { usePlatform } from "./platform"
 
 type SDKEventMap = {
   [key in Event["type"]]: Extract<Event, { type: key }>
@@ -12,14 +11,11 @@ type SDKEventMap = {
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
   init: (props: { directory: Accessor<string> }) => {
-    const platform = usePlatform()
     const globalSDK = useGlobalSDK()
 
     const directory = createMemo(props.directory)
     const client = createMemo(() =>
-      createOpencodeClient({
-        baseUrl: globalSDK.url,
-        fetch: platform.fetch,
+      globalSDK.createClient({
         directory: directory(),
         throwOnError: true,
       }),
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       get url() {
         return globalSDK.url
       },
+      createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
+        return globalSDK.createClient(opts)
+      },
     }
   },
 })

+ 111 - 75
packages/app/src/context/server.tsx

@@ -1,5 +1,5 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo, onCleanup } from "solid-js"
+import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { Persist, persisted } from "@/utils/persist"
@@ -15,9 +15,10 @@ export function normalizeServerUrl(input: string) {
   return withProtocol.replace(/\/+$/, "")
 }
 
-export function serverDisplayName(url: string) {
-  if (!url) return ""
-  return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
+export function serverDisplayName(conn?: ServerConnection.Any) {
+  if (!conn) return ""
+  if (conn.displayName) return conn.displayName
+  return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
 }
 
 function projectsKey(url: string) {
@@ -27,80 +28,104 @@ function projectsKey(url: string) {
   return url
 }
 
+export namespace ServerConnection {
+  type Base = { displayName?: string }
+
+  export type HttpBase = {
+    url: string
+    username?: string
+    password?: string
+  }
+
+  // Regular web connections
+  export type Http = {
+    type: "http"
+    http: HttpBase
+  } & Base
+
+  export type Sidecar = {
+    type: "sidecar"
+    http: HttpBase
+  } & (
+    | // Regular desktop server
+    { variant: "base" }
+    // WSL server (windows only)
+    | {
+        variant: "wsl"
+        distro: string
+      }
+  ) &
+    Base
+
+  // Remote server desktop can SSH into
+  export type Ssh = {
+    type: "ssh"
+    host: string
+    // SSH client exposes an HTTP server for the app to use as a proxy
+    http: HttpBase
+  } & Base
+
+  export type Any =
+    | Http
+    // All these are desktop-only
+    | (Sidecar | Ssh)
+
+  export const key = (conn: Any): Key => {
+    switch (conn.type) {
+      case "http":
+        return Key.make(conn.http.url)
+      case "sidecar": {
+        if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
+        return Key.make("sidecar")
+      }
+      case "ssh":
+        return Key.make(`ssh:${conn.host}`)
+    }
+  }
+
+  export type Key = string & { _brand: "Key" }
+  export const Key = { make: (v: string) => v as Key }
+}
+
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
-  init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
+  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
     const platform = usePlatform()
 
     const [store, setStore, _, ready] = persisted(
       Persist.global("server", ["server.v3"]),
       createStore({
         list: [] as string[],
-        currentSidecarUrl: "",
         projects: {} as Record<string, StoredProject[]>,
         lastProject: {} as Record<string, string>,
       }),
     )
 
+    const allServers = createMemo(
+      (): Array<ServerConnection.Any> => [
+        ...(props.servers ?? []),
+        ...store.list.map((value) => ({
+          type: "http" as const,
+          http: typeof value === "string" ? { url: value } : value,
+        })),
+      ],
+    )
+
     const [state, setState] = createStore({
-      active: "",
+      active: props.defaultServer,
       healthy: undefined as boolean | undefined,
     })
 
     const healthy = () => state.healthy
 
-    const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
-
-    function reconcileStartup() {
-      const fallback = defaultUrl()
-      if (!fallback) return
-
-      const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
-      const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
-      if (!props.isSidecar) {
-        batch(() => {
-          setStore("list", list)
-          if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
-          setState("active", fallback)
-        })
-        return
-      }
-
-      const nextList = list.includes(fallback) ? list : [...list, fallback]
-      batch(() => {
-        setStore("list", nextList)
-        setStore("currentSidecarUrl", fallback)
-        setState("active", fallback)
-      })
-    }
-
-    function updateServerList(url: string, remove = false) {
-      if (remove) {
-        const list = store.list.filter((x) => x !== url)
-        const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
-        batch(() => {
-          setStore("list", list)
-          setState("active", next)
-        })
-        return
-      }
-
-      batch(() => {
-        if (!store.list.includes(url)) {
-          setStore("list", store.list.length, url)
-        }
-        setState("active", url)
-      })
-    }
-
-    function startHealthPolling(url: string) {
+    function startHealthPolling(conn: ServerConnection.Any) {
       let alive = true
       let busy = false
 
       const run = () => {
         if (busy) return
         busy = true
-        void check(url)
+        void check(conn)
           .then((next) => {
             if (!alive) return
             setState("healthy", next)
@@ -118,59 +143,70 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       }
     }
 
-    function setActive(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
-      setState("active", url)
+    function setActive(input: ServerConnection.Key) {
+      if (state.active !== input) setState("active", input)
     }
 
     function add(input: string) {
       const url = normalizeServerUrl(input)
       if (!url) return
-      updateServerList(url)
+      return batch(() => {
+        const http: ServerConnection.HttpBase = { url }
+        if (!store.list.includes(url)) {
+          setStore("list", store.list.length, url)
+        }
+        const conn: ServerConnection.Http = { type: "http", http }
+        setState("active", ServerConnection.key(conn))
+        return conn
+      })
     }
 
-    function remove(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
-      updateServerList(url, true)
+    function remove(key: ServerConnection.Key) {
+      const list = store.list.filter((x) => x !== key)
+      batch(() => {
+        setStore("list", list)
+        if (state.active === key) {
+          const next = list[0]
+          setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
+        }
+      })
     }
 
-    createEffect(() => {
-      if (!ready()) return
-      if (state.active) return
-      reconcileStartup()
-    })
-
     const isReady = createMemo(() => ready() && !!state.active)
 
     const fetcher = platform.fetch ?? globalThis.fetch
-    const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
+    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
 
     createEffect(() => {
-      const url = state.active
-      if (!url) return
+      const current_ = current()
+      if (!current_) return
 
       setState("healthy", undefined)
-      onCleanup(startHealthPolling(url))
+      onCleanup(startHealthPolling(current_))
     })
 
     const origin = createMemo(() => projectsKey(state.active))
     const projectsList = createMemo(() => store.projects[origin()] ?? [])
     const isLocal = createMemo(() => origin() === "local")
+    const current: Accessor<ServerConnection.Any | undefined> = createMemo(
+      () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
+    )
 
     return {
       ready: isReady,
       healthy,
       isLocal,
-      get url() {
+      get key() {
         return state.active
       },
       get name() {
-        return serverDisplayName(state.active)
+        return serverDisplayName(current())
       },
       get list() {
-        return store.list
+        return allServers()
+      },
+      get current() {
+        return current()
       },
       setActive,
       add,

+ 15 - 2
packages/app/src/entry.tsx

@@ -1,11 +1,14 @@
 // @refresh reload
+
+import { iife } from "@opencode-ai/util/iife"
 import { render } from "solid-js/web"
 import { AppBaseProviders, AppInterface } from "@/app"
-import { Platform, PlatformProvider } from "@/context/platform"
+import { type Platform, PlatformProvider } from "@/context/platform"
 import { dict as en } from "@/i18n/en"
 import { dict as zh } from "@/i18n/zh"
 import { handleNotificationClick } from "@/utils/notification-click"
 import pkg from "../package.json"
+import { ServerConnection } from "./context/server"
 
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 
@@ -107,12 +110,22 @@ const platform: Platform = {
   setDefaultServerUrl: writeDefaultServerUrl,
 }
 
+const defaultUrl = iife(() => {
+  const lsDefault = readDefaultServerUrl()
+  if (lsDefault) return lsDefault
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (import.meta.env.DEV)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+  return location.origin
+})
+
 if (root instanceof HTMLElement) {
+  const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
   render(
     () => (
       <PlatformProvider value={platform}>
         <AppBaseProviders>
-          <AppInterface />
+          <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
         </AppBaseProviders>
       </PlatformProvider>
     ),

+ 2 - 1
packages/app/src/index.ts

@@ -1,4 +1,5 @@
-export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
 export { AppBaseProviders, AppInterface } from "./app"
 export { useCommand } from "./context/command"
+export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
+export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"

+ 15 - 6
packages/app/src/utils/server-health.test.ts

@@ -1,6 +1,11 @@
 import { describe, expect, test } from "bun:test"
+import type { ServerConnection } from "@/context/server"
 import { checkServerHealth } from "./server-health"
 
+const server: ServerConnection.HttpBase = {
+  url: "http://localhost:4096",
+}
+
 function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
   if (init?.signal) return init.signal
   if (input instanceof Request) return input.signal
@@ -15,7 +20,7 @@ describe("checkServerHealth", () => {
         headers: { "content-type": "application/json" },
       })) as unknown as typeof globalThis.fetch
 
-    const result = await checkServerHealth("http://localhost:4096", fetch)
+    const result = await checkServerHealth(server, fetch)
 
     expect(result).toEqual({ healthy: true, version: "1.2.3" })
   })
@@ -25,7 +30,7 @@ describe("checkServerHealth", () => {
       throw new Error("network")
     }) as unknown as typeof globalThis.fetch
 
-    const result = await checkServerHealth("http://localhost:4096", fetch)
+    const result = await checkServerHealth(server, fetch)
 
     expect(result).toEqual({ healthy: false })
   })
@@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
         )
       })) as unknown as typeof globalThis.fetch
 
-    const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => {
+    const result = await checkServerHealth(server, fetch, {
+      timeoutMs: 10,
+    }).finally(() => {
       if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
       if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
     })
@@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
     }) as unknown as typeof globalThis.fetch
 
     const abort = new AbortController()
-    await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
+    await checkServerHealth(server, fetch, {
+      signal: abort.signal,
+    })
 
     expect(signal).toBe(abort.signal)
   })
@@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
       })
     }) as unknown as typeof globalThis.fetch
 
-    const result = await checkServerHealth("http://localhost:4096", fetch, {
+    const result = await checkServerHealth(server, fetch, {
       retryCount: 2,
       retryDelayMs: 1,
     })
@@ -103,7 +112,7 @@ describe("checkServerHealth", () => {
       throw new TypeError("network")
     }) as unknown as typeof globalThis.fetch
 
-    const result = await checkServerHealth("http://localhost:4096", fetch, {
+    const result = await checkServerHealth(server, fetch, {
       retryCount: 2,
       retryDelayMs: 1,
     })

+ 9 - 5
packages/app/src/utils/server-health.ts

@@ -1,4 +1,5 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import type { ServerConnection } from "@/context/server"
+import { createSdkForServer } from "./server"
 
 export type ServerHealth = { healthy: boolean; version?: string }
 
@@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) {
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
   if (timeout) {
     try {
-      return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
+      return {
+        signal: timeout.call(AbortSignal, timeoutMs),
+        clear: undefined as (() => void) | undefined,
+      }
     } catch {}
   }
   const controller = new AbortController()
@@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
 }
 
 export async function checkServerHealth(
-  url: string,
+  server: ServerConnection.HttpBase,
   fetch: typeof globalThis.fetch,
   opts?: CheckServerHealthOptions,
 ): Promise<ServerHealth> {
@@ -67,8 +71,8 @@ export async function checkServerHealth(
       .catch(() => ({ healthy: false }))
   }
   const attempt = (count: number): Promise<ServerHealth> =>
-    createOpencodeClient({
-      baseUrl: url,
+    createSdkForServer({
+      server,
       fetch,
       signal,
     })

+ 22 - 0
packages/app/src/utils/server.ts

@@ -0,0 +1,22 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import type { ServerConnection } from "@/context/server"
+
+export function createSdkForServer({
+  server,
+  ...config
+}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
+  server: ServerConnection.HttpBase
+}) {
+  const auth = (() => {
+    if (!server.password) return
+    return {
+      Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
+    }
+  })()
+
+  return createOpencodeClient({
+    ...config,
+    headers: { ...config.headers, ...auth },
+    baseUrl: server.url,
+  })
+}

+ 2 - 1
packages/app/tsconfig.json

@@ -22,5 +22,6 @@
     }
   },
   "include": ["src", "package.json"],
-  "exclude": ["dist", "ts-dist"]
+  "exclude": ["dist", "ts-dist"],
+  "references": [{ "path": "../sdk/js" }]
 }

+ 38 - 40
packages/desktop/src/index.tsx

@@ -1,36 +1,37 @@
 // @refresh reload
-import { webviewZoom } from "./webview-zoom"
-import { render } from "solid-js/web"
+
 import {
   AppBaseProviders,
   AppInterface,
+  handleNotificationClick,
+  type Platform,
   PlatformProvider,
-  Platform,
+  ServerConnection,
   useCommand,
-  handleNotificationClick,
 } from "@opencode-ai/app"
-import { open, save } from "@tauri-apps/plugin-dialog"
+import { Splash } from "@opencode-ai/ui/logo"
+import type { AsyncStorage } from "@solid-primitives/storage"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+import { readImage } from "@tauri-apps/plugin-clipboard-manager"
 import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
+import { open, save } from "@tauri-apps/plugin-dialog"
+import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
+import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
 import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
-import { open as shellOpen } from "@tauri-apps/plugin-shell"
 import { type as ostype } from "@tauri-apps/plugin-os"
-import { check, Update } from "@tauri-apps/plugin-updater"
-import { getCurrentWindow } from "@tauri-apps/api/window"
-import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
 import { relaunch } from "@tauri-apps/plugin-process"
-import { AsyncStorage } from "@solid-primitives/storage"
-import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
+import { open as shellOpen } from "@tauri-apps/plugin-shell"
 import { Store } from "@tauri-apps/plugin-store"
-import { Splash } from "@opencode-ai/ui/logo"
-import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
-import { readImage } from "@tauri-apps/plugin-clipboard-manager"
-
-import { UPDATER_ENABLED } from "./updater"
-import { initI18n, t } from "./i18n"
+import { check, type Update } from "@tauri-apps/plugin-updater"
+import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
+import { render } from "solid-js/web"
 import pkg from "../package.json"
+import { initI18n, t } from "./i18n"
+import { UPDATER_ENABLED } from "./updater"
+import { webviewZoom } from "./webview-zoom"
 import "./styles.css"
-import { commands, InitStep } from "./bindings"
 import { Channel } from "@tauri-apps/api/core"
+import { commands, type InitStep } from "./bindings"
 import { createMenu } from "./menu"
 
 const root = document.getElementById("root")
@@ -58,7 +59,7 @@ const listenForDeepLinks = async () => {
   await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
 }
 
-const createPlatform = (password: Accessor<string | null>): Platform => {
+const createPlatform = (): Platform => {
   const os = (() => {
     const type = ostype()
     if (type === "macos" || type === "windows" || type === "linux") return type
@@ -344,22 +345,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
     },
 
     fetch: (input, init) => {
-      const pw = password()
-
-      const addHeader = (headers: Headers, password: string) => {
-        headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
-      }
-
       if (input instanceof Request) {
-        if (pw) addHeader(input.headers, pw)
         return tauriFetch(input)
       } else {
-        const headers = new Headers(init?.headers)
-        if (pw) addHeader(headers, pw)
-        return tauriFetch(input, {
-          ...(init as any),
-          headers: headers,
-        })
+        return tauriFetch(input, init)
       }
     },
 
@@ -417,7 +406,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
       return new Promise<File | null>((resolve) => {
         canvas.toBlob((blob) => {
           if (!blob) return resolve(null)
-          resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
+          resolve(
+            new File([blob], `pasted-image-${Date.now()}.png`, {
+              type: "image/png",
+            }),
+          )
         }, "image/png")
       })
     },
@@ -431,9 +424,7 @@ createMenu((id) => {
 void listenForDeepLinks()
 
 render(() => {
-  const [serverPassword, setServerPassword] = createSignal<string | null>(null)
-
-  const platform = createPlatform(() => serverPassword())
+  const platform = createPlatform()
 
   function handleClick(e: MouseEvent) {
     const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -455,9 +446,16 @@ render(() => {
       <AppBaseProviders>
         <ServerGate>
           {(data) => {
-            setServerPassword(data().password)
-            window.__OPENCODE__ ??= {}
-            window.__OPENCODE__.serverPassword = data().password ?? undefined
+            const server: ServerConnection.Sidecar = {
+              displayName: "Local Server",
+              type: "sidecar",
+              variant: "base",
+              http: {
+                url: data().url,
+                username: "opencode",
+                password: data().password ?? undefined,
+              },
+            }
 
             function Inner() {
               const cmd = useCommand()
@@ -468,7 +466,7 @@ render(() => {
             }
 
             return (
-              <AppInterface defaultUrl={data().url} isSidecar>
+              <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
                 <Inner />
               </AppInterface>
             )

+ 12 - 2
packages/sdk/js/package.json

@@ -12,8 +12,18 @@
     ".": "./src/index.ts",
     "./client": "./src/client.ts",
     "./server": "./src/server.ts",
-    "./v2": "./src/v2/index.ts",
-    "./v2/client": "./src/v2/client.ts",
+    "./v2": {
+      "types": "./dist/src/v2/index.d.ts",
+      "default": "./src/v2/index.ts"
+    },
+    "./v2/client": {
+      "types": "./dist/src/v2/client.d.ts",
+      "default": "./src/v2/client.ts"
+    },
+    "./v2/gen/client": {
+      "types": "./dist/src/v2/gen/client/index.d.ts",
+      "default": "./src/v2/gen/client/index.ts"
+    },
     "./v2/server": "./src/v2/server.ts"
   },
   "files": [

+ 3 - 3
packages/sdk/js/tsconfig.json

@@ -6,8 +6,8 @@
     "module": "nodenext",
     "declaration": true,
     "moduleResolution": "nodenext",
-    "lib": ["es2022", "dom", "dom.iterable"]
+    "lib": ["es2022", "dom", "dom.iterable"],
+    "composite": true
   },
-  "include": ["src"],
-  "exclude": ["src/gen"]
+  "include": ["src"]
 }