Bladeren bron

feat: desktop v2 everything WSL (#23407)

Luke Parker 3 maanden geleden
bovenliggende
commit
bd7eb0603f
37 gewijzigde bestanden met toevoegingen van 2620 en 202 verwijderingen
  1. 1 0
      packages/app/package.json
  2. 8 6
      packages/app/src/app.tsx
  3. 9 1
      packages/app/src/components/dialog-select-file.tsx
  4. 20 1
      packages/app/src/components/prompt-input.tsx
  5. 25 0
      packages/app/src/components/prompt-input/server-attachment.test.ts
  6. 7 0
      packages/app/src/components/prompt-input/server-attachment.ts
  7. 7 3
      packages/app/src/components/settings-v2/servers.tsx
  8. 6 11
      packages/app/src/components/status-popover-body.tsx
  9. 3 5
      packages/app/src/context/platform.tsx
  10. 41 1
      packages/app/src/context/server.test.ts
  11. 16 9
      packages/app/src/context/server.tsx
  12. 53 0
      packages/app/src/i18n/en.ts
  13. 21 1
      packages/app/src/index.ts
  14. 10 1
      packages/app/src/pages/home.tsx
  15. 36 0
      packages/app/src/wsl/context.tsx
  16. 623 0
      packages/app/src/wsl/dialog-add-server.tsx
  17. 57 0
      packages/app/src/wsl/settings-model.test.ts
  18. 19 0
      packages/app/src/wsl/settings-model.ts
  19. 167 0
      packages/app/src/wsl/settings.tsx
  20. 87 0
      packages/app/src/wsl/types.ts
  21. 1 20
      packages/desktop/src/main/apps.ts
  22. 1 1
      packages/desktop/src/main/constants.ts
  23. 43 23
      packages/desktop/src/main/index.ts
  24. 4 12
      packages/desktop/src/main/ipc.ts
  25. 1 12
      packages/desktop/src/main/server.ts
  26. 64 0
      packages/desktop/src/main/wsl/ipc.ts
  27. 26 0
      packages/desktop/src/main/wsl/policy.ts
  28. 400 0
      packages/desktop/src/main/wsl/runtime.ts
  29. 93 0
      packages/desktop/src/main/wsl/servers.test.ts
  30. 440 0
      packages/desktop/src/main/wsl/servers.ts
  31. 129 0
      packages/desktop/src/main/wsl/sidecar.ts
  32. 31 0
      packages/desktop/src/main/wsl/startup.ts
  33. 24 4
      packages/desktop/src/preload/index.ts
  34. 16 4
      packages/desktop/src/preload/types.ts
  35. 60 87
      packages/desktop/src/renderer/index.tsx
  36. 43 0
      packages/desktop/src/renderer/wsl/connections.test.ts
  37. 28 0
      packages/desktop/src/renderer/wsl/connections.ts

+ 1 - 0
packages/app/package.json

@@ -6,6 +6,7 @@
   "exports": {
     ".": "./src/index.ts",
     "./desktop-menu": "./src/desktop-menu.ts",
+    "./wsl/types": "./src/wsl/types.ts",
     "./vite": "./vite.js",
     "./index.css": "./src/index.css"
   },

+ 8 - 6
packages/app/src/app.tsx

@@ -44,6 +44,7 @@ import { ServerConnection, ServerProvider, serverName, useServer } from "@/conte
 import { SettingsProvider, useSettings } from "@/context/settings"
 import { TerminalProvider } from "@/context/terminal"
 import { TabsProvider } from "@/context/tabs"
+import { WslServersProvider } from "@/wsl/context"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
@@ -71,7 +72,6 @@ declare global {
     __OPENCODE__?: {
       updaterEnabled?: boolean
       deepLinks?: string[]
-      wsl?: boolean
     }
     api?: {
       setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -171,11 +171,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
               }}
             >
               <QueryProvider>
-                <DialogProvider>
-                  <MarkedProvider>
-                    <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
-                  </MarkedProvider>
-                </DialogProvider>
+                <WslServersProvider>
+                  <DialogProvider>
+                    <MarkedProvider>
+                      <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
+                    </MarkedProvider>
+                  </DialogProvider>
+                </WslServersProvider>
               </QueryProvider>
             </ErrorBoundary>
           </UiI18nBridge>

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

@@ -261,7 +261,11 @@ function createSessionEntries(props: {
   return { sessions }
 }
 
-export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
+export function DialogSelectFile(props: {
+  mode?: DialogSelectFileMode
+  onOpenFile?: (path: string) => void
+  onSelectFile?: (path: string) => void
+}) {
   const command = useCommand()
   const language = useLanguage()
   const layout = useLayout()
@@ -375,6 +379,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     }
 
     if (!item.path) return
+    if (props.onSelectFile) {
+      props.onSelectFile(item.path)
+      return
+    }
     open(item.path)
   }
 

+ 20 - 1
packages/app/src/components/prompt-input.tsx

@@ -52,6 +52,7 @@ import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
+import { serverAttachmentFile } from "./prompt-input/server-attachment"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -465,7 +466,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
 
-  const pick = () => fileInputRef?.click()
+  const pick = () => {
+    if (server.isLocal()) {
+      fileInputRef?.click()
+      return
+    }
+    void import("@/components/dialog-select-file").then((module) =>
+      dialog.show(() => (
+        <module.DialogSelectFile
+          mode="files"
+          onSelectFile={(path) => {
+            void sdk.client.v2.fs
+              .read({ path })
+              .then((response) => response.data?.data)
+              .then((data) => data && addAttachments([serverAttachmentFile(path, data)]))
+          }}
+        />
+      )),
+    )
+  }
 
   const setMode = (mode: "normal" | "shell") => {
     setStore("mode", mode)

+ 25 - 0
packages/app/src/components/prompt-input/server-attachment.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { serverAttachmentFile } from "./server-attachment"
+
+describe("serverAttachmentFile", () => {
+  test("creates a file from server text content", async () => {
+    const file = serverAttachmentFile("docs/readme.txt", { type: "text", content: "hello", mime: "text/plain" })
+
+    expect(file.name).toBe("readme.txt")
+    expect(file.type).toBe("text/plain")
+    expect(await file.text()).toBe("hello")
+  })
+
+  test("creates a file from server base64 content", async () => {
+    const file = serverAttachmentFile("images/pixel.png", {
+      type: "binary",
+      content: "aGVsbG8=",
+      encoding: "base64",
+      mime: "image/png",
+    })
+
+    expect(file.name).toBe("pixel.png")
+    expect(file.type).toBe("image/png")
+    expect(await file.text()).toBe("hello")
+  })
+})

+ 7 - 0
packages/app/src/components/prompt-input/server-attachment.ts

@@ -0,0 +1,7 @@
+import { getFilename } from "@opencode-ai/core/util/path"
+import type { FileSystemBinaryContent, FileSystemTextContent } from "@opencode-ai/sdk/v2"
+
+export function serverAttachmentFile(path: string, data: FileSystemTextContent | FileSystemBinaryContent) {
+  const content = data.type === "text" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
+  return new File([content], getFilename(path), { type: data.mime })
+}

+ 7 - 3
packages/app/src/components/settings-v2/servers.tsx

@@ -14,6 +14,7 @@ import { ServerConnection, serverName } from "@/context/server"
 import { useServerManagementController } from "../dialog-select-server"
 import { DialogServerV2 } from "./dialog-server-v2"
 import { SettingsListV2 } from "./parts/list"
+import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
 import "./settings-v2.css"
 
 export const SettingsServersV2: Component = () => {
@@ -21,11 +22,12 @@ export const SettingsServersV2: Component = () => {
   const language = useLanguage()
   const controller = useServerManagementController()
   const [store, setStore] = createStore({ filter: "" })
+  const wslServers = useFilteredWslServers(() => store.filter)
 
-  const showSearch = createMemo(() => controller.sortedItems().length > 1)
+  const showSearch = createMemo(() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1)
 
   const filtered = createMemo(() => {
-    const items = controller.sortedItems()
+    const items = controller.sortedItems().filter((item) => !isWslServer(item))
     const query = store.filter.trim()
     if (!query) return items
     return fuzzysort
@@ -54,6 +56,7 @@ export const SettingsServersV2: Component = () => {
           <ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
             {language.t("dialog.server.add.button")}
           </ButtonV2>
+          <WslAddServerButton />
         </div>
         <Show when={showSearch()}>
           <div class="settings-v2-tab-search">
@@ -85,7 +88,7 @@ export const SettingsServersV2: Component = () => {
 
       <div class="settings-v2-tab-body settings-v2-servers">
         <Show
-          when={filtered().length > 0}
+          when={filtered().length > 0 || wslServers().length > 0}
           fallback={
             <div class="settings-v2-servers-status">
               <span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
@@ -96,6 +99,7 @@ export const SettingsServersV2: Component = () => {
           }
         >
           <SettingsListV2>
+            <WslServerSettings controller={controller} servers={wslServers} />
             <For each={filtered()}>
               {(item) => {
                 const key = ServerConnection.key(item)

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

@@ -12,7 +12,7 @@ import { ServerHealthIndicator, 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 { ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { type ServerHealth } from "@/utils/server-health"
 import { useQueryOptions } from "@/context/server-sync"
@@ -20,8 +20,6 @@ import { pathKey } from "@/utils/path-key"
 import { useGlobal } from "@/context/global"
 import { useSettings } from "@/context/settings"
 
-const pollMs = 10_000
-
 const pluginEmptyMessage = (value: string, file: string): JSXElement => {
   const parts = value.split(file)
   if (parts.length === 1) return value
@@ -60,7 +58,7 @@ const useDefaultServerKey = (
   get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
 ) => {
   const [state, setState] = createStore({
-    url: undefined as string | undefined,
+    key: undefined as ServerConnection.Key | undefined,
     tick: 0,
   })
 
@@ -69,7 +67,7 @@ const useDefaultServerKey = (
     let dead = false
     const result = get?.()
     if (!result) {
-      setState("url", undefined)
+      setState("key", undefined)
       onCleanup(() => {
         dead = true
       })
@@ -79,7 +77,7 @@ const useDefaultServerKey = (
     if (result instanceof Promise) {
       void result.then((next) => {
         if (dead) return
-        setState("url", next ? normalizeServerUrl(next) : undefined)
+        setState("key", next ?? undefined)
       })
       onCleanup(() => {
         dead = true
@@ -87,7 +85,7 @@ const useDefaultServerKey = (
       return
     }
 
-    setState("url", normalizeServerUrl(result))
+    setState("key", ServerConnection.Key.make(result))
     onCleanup(() => {
       dead = true
     })
@@ -95,9 +93,7 @@ const useDefaultServerKey = (
 
   return {
     key: () => {
-      const u = state.url
-      if (!u) return
-      return ServerConnection.key({ type: "http", http: { url: u } })
+      return state.key
     },
     refresh: () => setState("tick", (value) => value + 1),
   }
@@ -160,7 +156,6 @@ export function StatusPopoverServerBody() {
   const dialog = useDialog()
   const language = useLanguage()
   const navigate = useNavigate()
-
   let dialogRun = 0
   let dialogDead = false
   onCleanup(() => {

+ 3 - 5
packages/app/src/context/platform.tsx

@@ -3,6 +3,7 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 import type { Accessor } from "solid-js"
 import type { DesktopMenuAction } from "../desktop-menu"
 import { ServerConnection } from "./server"
+import type { WslServersPlatform } from "../wsl/types"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -75,11 +76,8 @@ export type Platform = {
   /** Set the default server URL to use on app startup (platform-specific) */
   setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
 
-  /** Get the configured WSL integration (desktop only) */
-  getWslEnabled?(): Promise<boolean>
-
-  /** Set the configured WSL integration (desktop only) */
-  setWslEnabled?(config: boolean): Promise<void> | void
+  /** Manage WSL sidecar servers (Electron on Windows only) */
+  wslServers?: WslServersPlatform
 
   /** Get the preferred display backend (desktop only) */
   getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

+ 41 - 1
packages/app/src/context/server.test.ts

@@ -1,7 +1,13 @@
 import { describe, expect, test } from "bun:test"
 import { createRoot, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
-import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
+import {
+  createServerProjects,
+  migrateCanonicalLocalServerState,
+  nextServerAfterRemoval,
+  resolveServerList,
+  ServerConnection,
+} from "./server"
 import { ServerScope } from "@/utils/server-scope"
 
 describe("resolveServerList", () => {
@@ -55,6 +61,40 @@ describe("resolveServerList", () => {
   })
 })
 
+test("treats WSL sidecars as remote server connections", () => {
+  expect(
+    ServerConnection.local({
+      type: "sidecar",
+      variant: "wsl",
+      distro: "Debian",
+      http: { url: "http://127.0.0.1:4097" },
+    }),
+  ).toBe(false)
+  expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe(
+    true,
+  )
+  expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true)
+  expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
+})
+
+test("active server removal falls back across built-in and persisted servers", () => {
+  const local = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } as const
+  const debian = {
+    type: "sidecar",
+    variant: "wsl",
+    distro: "Debian",
+    http: { url: "http://127.0.0.1:4097" },
+  } as const
+
+  expect(
+    nextServerAfterRemoval(
+      [local, debian],
+      ServerConnection.Key.make("wsl:Debian"),
+      ServerConnection.Key.make("sidecar"),
+    ),
+  ).toBe(ServerConnection.Key.make("sidecar"))
+})
+
 describe("createServerProjects", () => {
   test("keeps active and explicit server buckets in one reactive store", () => {
     createRoot((dispose) => {

+ 16 - 9
packages/app/src/context/server.tsx

@@ -145,7 +145,7 @@ export function resolveServerList(input: {
 }
 
 export namespace ServerConnection {
-  type Base = { displayName?: string }
+  type Base = { displayName?: string; label?: string }
 
   export type HttpBase = {
     url: string
@@ -204,6 +204,18 @@ export namespace ServerConnection {
   export const Key = { make: (v: string) => v as Key }
 
   export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
+  export const local = (conn?: Any) =>
+    !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
+}
+
+export function nextServerAfterRemoval(
+  servers: ServerConnection.Any[],
+  removed: ServerConnection.Key,
+  fallback: ServerConnection.Key,
+) {
+  const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
+  const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
+  return next ? ServerConnection.key(next) : fallback
 }
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
@@ -257,13 +269,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     }
 
     function remove(key: ServerConnection.Key) {
+      const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
       const list = store.list.filter((x) => url(x) !== key)
       batch(() => {
         setStore("list", list)
-        if (state.active === key) {
-          const next = list[0]
-          setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
-        }
+        if (state.active === key) setState("active", next)
       })
     }
 
@@ -282,10 +292,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     const current: Accessor<ServerConnection.Any | undefined> = createMemo(
       () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
     )
-    const isLocal = createMemo(() => {
-      const c = current()
-      return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
-    })
+    const isLocal = createMemo(() => ServerConnection.local(current()))
 
     return {
       ready: isReady,

+ 53 - 0
packages/app/src/i18n/en.ts

@@ -349,6 +349,59 @@ export const dict = {
   "dialog.server.menu.delete": "Delete",
   "dialog.server.current": "Current Server",
   "dialog.server.status.default": "Default",
+  "wsl.server.add": "Add WSL server",
+  "wsl.server.addShort": "Add WSL",
+  "wsl.server.label": "WSL",
+  "wsl.server.menu.label": "WSL server",
+  "wsl.server.retryStart": "Retry start",
+  "wsl.server.updating": "Updating...",
+  "wsl.onboarding.step.distro": "Choose distro",
+  "wsl.onboarding.step.opencode": "OpenCode",
+  "wsl.onboarding.checkingRuntime": "Checking WSL...",
+  "wsl.onboarding.restartRequired": "Windows needs a restart to finish installing WSL.",
+  "wsl.onboarding.ready": "WSL is ready.",
+  "wsl.onboarding.required": "WSL is required to continue.",
+  "wsl.onboarding.checkingDistros": "Checking distros...",
+  "wsl.onboarding.installingDistro": "Installing {{distro}}...",
+  "wsl.onboarding.checkingDistro": "Checking {{distro}}...",
+  "wsl.onboarding.listingDistros": "Listing distros...",
+  "wsl.onboarding.distroReady": "{{distro}} is ready.",
+  "wsl.onboarding.distroNotInstalled": "{{distro}} is not installed yet.",
+  "wsl.onboarding.openDistroOnce": "Open {{distro}} once to finish setup.",
+  "wsl.onboarding.finishingDistro": "Finishing setup for {{distro}}.",
+  "wsl.onboarding.pickDistro": "Pick a distro or install one below.",
+  "wsl.onboarding.checkingOpencode": "Checking OpenCode...",
+  "wsl.onboarding.checkingOpencodeIn": "Checking OpenCode in {{distro}}...",
+  "wsl.onboarding.updatingOpencode": "Updating OpenCode...",
+  "wsl.onboarding.updatingOpencodeIn": "Updating OpenCode in {{distro}}...",
+  "wsl.onboarding.updateOpencodeIn": "Update OpenCode in {{distro}}.",
+  "wsl.onboarding.updateOpencode": "Update OpenCode",
+  "wsl.onboarding.opencodeReadyIn": "OpenCode is ready in {{distro}}.",
+  "wsl.onboarding.opencodeReady": "OpenCode is ready.",
+  "wsl.onboarding.installOpencodeIn": "Install OpenCode in {{distro}}.",
+  "wsl.onboarding.installOpencode": "Install OpenCode",
+  "wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
+  "wsl.onboarding.loadFailed": "Failed to load WSL state.",
+  "wsl.onboarding.loading": "Loading...",
+  "wsl.onboarding.installWsl": "Install WSL",
+  "wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
+  "wsl.onboarding.next": "Next",
+  "wsl.onboarding.refresh": "Refresh",
+  "wsl.onboarding.allDistrosAdded": "All installed distros are already added.",
+  "wsl.onboarding.noDistros": "No distros detected yet.",
+  "wsl.onboarding.install": "Install",
+  "wsl.onboarding.installing": "Installing...",
+  "wsl.onboarding.installDistro": "Install distro",
+  "wsl.onboarding.wsl2Required": "WSL 2 is required.",
+  "wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
+  "wsl.onboarding.openTerminal": "Open terminal",
+  "wsl.onboarding.path": "Path: {{path}}",
+  "wsl.onboarding.notFound": "not found",
+  "wsl.onboarding.version": "Version: {{version}}",
+  "wsl.onboarding.unknown": "unknown",
+  "wsl.onboarding.desktopVersion": "desktop {{version}}",
+  "wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
+  "wsl.onboarding.adding": "Adding...",
   "server.row.noUsername": "no username",
 
   "dialog.project.edit.title": "Edit project",

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

@@ -2,6 +2,26 @@ export { AppBaseProviders, AppInterface } from "./app"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
 export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
-export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
+export { useWslServers } from "./wsl/context"
+export {
+  type DisplayBackend,
+  type FatalRendererErrorLog,
+  type Platform,
+  PlatformProvider,
+} from "./context/platform"
+export {
+  type WslDistroProbe,
+  type WslInstalledDistro,
+  type WslJob,
+  type WslOnlineDistro,
+  type WslOpencodeCheck,
+  type WslRuntimeCheck,
+  type WslServerConfig,
+  type WslServerItem,
+  type WslServerRuntime,
+  type WslServersEvent,
+  type WslServersPlatform,
+  type WslServersState,
+} from "./wsl/types"
 export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"

+ 10 - 1
packages/app/src/pages/home.tsx

@@ -550,7 +550,16 @@ function HomeServerRow(props: {
         <div class="flex size-4 shrink-0 items-center justify-center">
           <ServerHealthIndicator health={props.health} />
         </div>
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
+        <span class="flex min-w-0 items-center gap-1">
+          <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
+          <Show when={props.server.label}>
+            {(label) => (
+              <span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
+                {label()}
+              </span>
+            )}
+          </Show>
+        </span>
       </button>
       <div
         class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"

+ 36 - 0
packages/app/src/wsl/context.tsx

@@ -0,0 +1,36 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
+import { createEffect, onCleanup } from "solid-js"
+import type { WslServersState } from "./types"
+import { usePlatform } from "../context/platform"
+
+const wslServersQueryKey = ["platform", "wslServers"] as const
+
+export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
+  name: "WslServers",
+  init: () => {
+    const platform = usePlatform()
+    const queryClient = useQueryClient()
+    const query = useQuery(() => {
+      const api = platform.wslServers
+      return queryOptions<WslServersState>({
+        queryKey: wslServersQueryKey,
+        queryFn: () => api!.getState(),
+        enabled: !!api,
+        staleTime: Number.POSITIVE_INFINITY,
+        gcTime: Number.POSITIVE_INFINITY,
+      })
+    })
+
+    createEffect(() => {
+      const api = platform.wslServers
+      if (!api) return
+      const off = api.subscribe((event) => {
+        queryClient.setQueryData(wslServersQueryKey, event.state)
+      })
+      onCleanup(off)
+    })
+
+    return query as typeof query & { readonly data: WslServersState | undefined }
+  },
+})

+ 623 - 0
packages/app/src/wsl/dialog-add-server.tsx

@@ -0,0 +1,623 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { showToast } from "@opencode-ai/ui/toast"
+import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useWslServers } from "./context"
+import { enterWslOpencodeStep } from "./settings-model"
+
+type WslServerStep = "wsl" | "distro" | "opencode"
+
+const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
+
+function isHiddenDistro(name: string) {
+  return /^docker-desktop(?:-data)?$/i.test(name)
+}
+
+interface DialogWslServerProps {
+  onAdded?: (distro: string) => void | Promise<void>
+}
+
+export function DialogAddWslServer(props: DialogWslServerProps = {}) {
+  const language = useLanguage()
+  const platform = usePlatform()
+  const dialog = useDialog()
+  const wslServers = useWslServers()
+  const api = platform.wslServers!
+  const [store, setStore] = createStore({
+    step: undefined as WslServerStep | undefined,
+    selectedDistro: null as string | null,
+    installTarget: undefined as string | undefined,
+    adding: false,
+  })
+  const current = () => wslServers.data
+  let disposed = false
+  onCleanup(() => {
+    disposed = true
+  })
+  const busy = createMemo(() => !!current()?.job || store.adding)
+  const visibleInstalledDistros = createMemo(() =>
+    (current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
+  )
+  const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
+  const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
+  const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
+  const addableInstalledDistros = createMemo(() => {
+    return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
+  })
+  const selectedDistro = createMemo(() => {
+    if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
+      return store.selectedDistro
+    }
+    const distro = defaultInstalledDistro()
+    if (distro && !existingServerDistros().has(distro.name)) return distro.name
+    return null
+  })
+  const selectedProbe = createMemo(() => {
+    const distro = selectedDistro()
+    if (!distro) return null
+    return current()?.distroProbes[distro] ?? null
+  })
+  const selectedInstalled = createMemo(() => {
+    const distro = selectedDistro()
+    if (!distro) return null
+    return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
+  })
+  const opencodeCheck = createMemo(() => {
+    const distro = selectedDistro()
+    if (!distro) return null
+    return current()?.opencodeChecks[distro] ?? null
+  })
+  const distroWarningProbe = createMemo(() => {
+    const probe = selectedProbe()
+    if (!probe) return null
+    if (distroReady()) return null
+    return probe
+  })
+  const distroUnavailableMessage = createMemo(() => {
+    const probe = distroWarningProbe()
+    const distro = selectedDistro()
+    if (!probe || probe.canExecute || !distro) return null
+    if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
+    return language.t("wsl.onboarding.openDistroOnce", { distro })
+  })
+  const distroMissingTools = createMemo(() => {
+    const probe = distroWarningProbe()
+    if (!probe?.canExecute) return null
+    if (probe.hasBash && probe.hasCurl) return null
+    return probe
+  })
+  const installableDistros = createMemo(() => {
+    const online = visibleOnlineDistros()
+    const installed = new Set(visibleInstalledDistros().map((item) => item.name))
+    const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
+    return online
+      .filter((item) => !installed.has(item.name))
+      .filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
+  })
+  const installTarget = createMemo(
+    () => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
+  )
+  const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
+  const installingOpencode = createMemo(() => {
+    const job = current()?.job
+    return job?.kind === "install-opencode" && job.distro === selectedDistro()
+  })
+  const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
+  const distroReady = createMemo(() => {
+    const probe = selectedProbe()
+    if (!probe || !selectedDistro()) return false
+    if (selectedInstalled()?.version === 1) return false
+    return probe.canExecute && probe.hasBash && probe.hasCurl
+  })
+  const opencodeReady = createMemo(() => {
+    const check = opencodeCheck()
+    return !!check?.resolvedPath && !check.error
+  })
+  const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
+  const addDisabled = createMemo(() => {
+    const job = current()?.job
+    if (!job) return store.adding
+    return store.adding || job.kind !== "probe-opencode"
+  })
+  const recommendedStep = createMemo<WslServerStep>(() => {
+    if (!wslReady()) return "wsl"
+    if (!distroReady()) return "distro"
+    return "opencode"
+  })
+  // activeStep falls back to recommendedStep when the user hasn't picked one.
+  // Once the user clicks a step tab we respect their choice rather than snapping
+  // them back when a probe result updates recommendedStep.
+  const activeStep = createMemo(() => store.step ?? recommendedStep())
+
+  const autoProbe = createMemo(() => {
+    const state = current()
+    if (!state || busy()) return null
+    if (state.pendingRestart) return null
+    if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
+    if (!wslReady()) return null
+    if (!state.installed.length && !state.online.length) {
+      return { key: "distros", run: () => api.refreshDistros() }
+    }
+    const distro = selectedDistro()
+    if (distro && !state.distroProbes[distro]) {
+      return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
+    }
+    if (!distro || !distroReady()) return null
+    if (!state.opencodeChecks[distro]) {
+      return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
+    }
+    return null
+  })
+
+  let lastAutoProbe: string | null = null
+  createEffect(() => {
+    const probe = autoProbe()
+    if (!probe || probe.key === lastAutoProbe) return
+    const key = probe.key
+    lastAutoProbe = key
+    void (async () => {
+      try {
+        await probe.run()
+      } catch (err) {
+        if (disposed) return
+        // Allow the same probe to run again when reactive inputs next change
+        // (e.g. user reselects a distro). Without this the user would be stuck
+        // on a transient wsl.exe failure until they pick a different distro.
+        if (lastAutoProbe === key) lastAutoProbe = null
+        requestError(language, err)
+      }
+    })()
+  })
+
+  const wslMessage = createMemo(() => {
+    const state = current()
+    if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
+    if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
+    if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
+    return state.runtime?.error ?? language.t("wsl.onboarding.required")
+  })
+
+  const distroMessage = createMemo(() => {
+    const state = current()
+    if (!state) return language.t("wsl.onboarding.checkingDistros")
+    const distro = selectedDistro()
+    if (state.job?.kind === "install-distro")
+      return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
+    if (state.job?.kind === "probe-distro")
+      return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
+    if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
+    if (distroUnavailableMessage()) return distroUnavailableMessage()!
+    if (selectedProbe() && distroReady())
+      return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
+    if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
+    return language.t("wsl.onboarding.pickDistro")
+  })
+
+  const opencodeMessage = createMemo(() => {
+    const state = current()
+    if (!state) return language.t("wsl.onboarding.checkingOpencode")
+    const distro = selectedDistro()
+    if (state.job?.kind === "install-opencode") {
+      return distro
+        ? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
+        : language.t("wsl.onboarding.updatingOpencode")
+    }
+    if (state.job?.kind === "probe-opencode") {
+      return distro
+        ? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
+        : language.t("wsl.onboarding.checkingOpencode")
+    }
+    if (opencodeCheck()?.error) return opencodeCheck()!.error
+    if (opencodeCheck()?.matchesDesktop === false) {
+      return distro
+        ? language.t("wsl.onboarding.updateOpencodeIn", { distro })
+        : language.t("wsl.onboarding.updateOpencode")
+    }
+    if (opencodeReady()) {
+      return distro
+        ? language.t("wsl.onboarding.opencodeReadyIn", { distro })
+        : language.t("wsl.onboarding.opencodeReady")
+    }
+    return distro
+      ? language.t("wsl.onboarding.installOpencodeIn", { distro })
+      : language.t("wsl.onboarding.chooseDistroFirst")
+  })
+
+  const run = async (action: () => Promise<unknown>) => {
+    try {
+      await action()
+    } catch (err) {
+      requestError(language, err)
+    }
+  }
+
+  const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
+    const distro = selectedDistro()
+    if (!distro) return
+    void run(() => action(distro))
+  }
+
+  const selectDistro = (name: string) => {
+    setStore("selectedDistro", name)
+    setStore("step", undefined)
+  }
+
+  const openOpencodeStep = () => {
+    const distro = selectedDistro()
+    if (!distro) return
+    void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
+  }
+
+  const finish = async () => {
+    const distro = selectedDistro()
+    if (!distro) return
+    setStore("adding", true)
+    try {
+      await api.addServer(distro)
+      if (props.onAdded) {
+        await props.onAdded(distro)
+      } else {
+        dialog.close()
+      }
+    } catch (err) {
+      requestError(language, err)
+    } finally {
+      setStore("adding", false)
+    }
+  }
+
+  const steps = createMemo(() => {
+    const active = activeStep()
+    const activeIndex = STEPS.indexOf(active)
+    const recommendedIndex = STEPS.indexOf(recommendedStep())
+    return STEPS.map((step) => {
+      const index = STEPS.indexOf(step)
+      return {
+        step,
+        title:
+          step === "wsl"
+            ? language.t("wsl.server.label")
+            : step === "distro"
+              ? language.t("wsl.onboarding.step.distro")
+              : language.t("wsl.onboarding.step.opencode"),
+        state:
+          active === step
+            ? "current"
+            : step === "wsl"
+              ? wslReady()
+                ? "done"
+                : "warning"
+              : step === "distro"
+                ? distroReady()
+                  ? "done"
+                  : index > activeIndex
+                    ? "locked"
+                    : "warning"
+                : opencodeCheck()?.matchesDesktop === false
+                  ? "warning"
+                  : opencodeReady()
+                    ? "done"
+                    : index > activeIndex
+                      ? "locked"
+                      : "warning",
+        locked: index > recommendedIndex,
+      }
+    })
+  })
+  const loadError = createMemo(() => {
+    const error = wslServers.error
+    if (!error) return language.t("wsl.onboarding.loadFailed")
+    return error instanceof Error ? error.message : String(error)
+  })
+
+  return (
+    <div class="px-5 pb-5 flex flex-col gap-4">
+      <Show
+        when={!wslServers.isPending}
+        fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
+      >
+        <Show
+          when={!wslServers.isError}
+          fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
+        >
+          <div class="flex gap-2 pb-1">
+            <For each={steps()}>
+              {(item) => (
+                <button
+                  type="button"
+                  class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
+                  classList={{
+                    "border-border-strong-base bg-surface-base-hover": item.state === "current",
+                    "border-icon-success-base/40 bg-surface-base": item.state === "done",
+                    "border-border-weak-base bg-background-base opacity-60": item.state === "locked",
+                    "border-icon-warning-base/40 bg-surface-base": item.state === "warning",
+                  }}
+                  disabled={item.locked}
+                  onClick={() => setStore("step", item.step)}
+                >
+                  <div class="text-13-medium text-text-strong">{item.title}</div>
+                </button>
+              )}
+            </For>
+          </div>
+
+          <Switch>
+            <Match when={activeStep() === "wsl"}>
+              <div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
+                <div class="flex items-center justify-between gap-3">
+                  <div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
+                  <Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
+                    <Button
+                      variant="secondary"
+                      size="large"
+                      disabled={busy()}
+                      onClick={() => void run(() => api.installWsl())}
+                    >
+                      {language.t("wsl.onboarding.installWsl")}
+                    </Button>
+                  </Show>
+                </div>
+                <div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
+                <Show when={current()?.pendingRestart}>
+                  <div class="rounded-md border border-border-weak-base px-3 py-3">
+                    <div class="text-12-regular text-text-warning-base">
+                      {language.t("wsl.onboarding.windowsRestartRequired")}
+                    </div>
+                  </div>
+                </Show>
+                <div class="flex items-center justify-end">
+                  <Button
+                    variant="secondary"
+                    size="large"
+                    disabled={busy() || !wslReady()}
+                    onClick={() => setStore("step", "distro")}
+                  >
+                    {language.t("wsl.onboarding.next")}
+                  </Button>
+                </div>
+              </div>
+            </Match>
+
+            <Match when={activeStep() === "distro"}>
+              <div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
+                <div class="flex items-center justify-between gap-3">
+                  <div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
+                  <Show when={selectedDistro()}>
+                    <Button
+                      variant="ghost"
+                      size="small"
+                      disabled={busy()}
+                      onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
+                    >
+                      {language.t("wsl.onboarding.refresh")}
+                    </Button>
+                  </Show>
+                </div>
+                <div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
+
+                <div class="flex flex-col gap-2">
+                  <Show
+                    when={addableInstalledDistros().length > 0}
+                    fallback={
+                      <div class="text-12-regular text-text-weak">
+                        {visibleInstalledDistros().length
+                          ? language.t("wsl.onboarding.allDistrosAdded")
+                          : current()?.runtime?.available
+                            ? language.t("wsl.onboarding.noDistros")
+                            : language.t("wsl.onboarding.checkingDistros")}
+                      </div>
+                    }
+                  >
+                    <For each={addableInstalledDistros()}>
+                      {(item) => (
+                        <button
+                          type="button"
+                          class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
+                          classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
+                          onClick={() => selectDistro(item.name)}
+                        >
+                          <div class="text-13-medium text-text-strong">{item.name}</div>
+                          <Show when={item.isDefault}>
+                            <div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
+                          </Show>
+                        </button>
+                      )}
+                    </For>
+                  </Show>
+                </div>
+
+                <Show when={installableDistros().length > 0}>
+                  <div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
+                    <div class="px-1 flex items-center justify-between gap-3">
+                      <div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
+                      <div class="flex items-center gap-2 shrink-0">
+                        <Show when={installingDistro()}>
+                          <Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
+                        </Show>
+                        <Button
+                          variant="secondary"
+                          size="small"
+                          disabled={busy() || !installTarget()}
+                          onClick={() => void run(() => api.installDistro(installTarget()!.name))}
+                        >
+                          {installingDistro()
+                            ? language.t("wsl.onboarding.installing")
+                            : language.t("wsl.onboarding.install")}
+                        </Button>
+                      </div>
+                    </div>
+                    <div
+                      role="radiogroup"
+                      aria-label={language.t("wsl.onboarding.installDistro")}
+                      class="max-h-52 overflow-y-auto rounded-md bg-background-base"
+                    >
+                      <For each={installableDistros()}>
+                        {(item) => {
+                          const selected = () => installTarget()?.name === item.name
+                          return (
+                            <button
+                              type="button"
+                              role="radio"
+                              aria-checked={selected()}
+                              disabled={busy()}
+                              class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
+                              classList={{
+                                "bg-surface-raised-base": selected(),
+                                "hover:bg-surface-base": !selected(),
+                              }}
+                              onClick={() => setStore("installTarget", item.name)}
+                            >
+                              <div
+                                class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
+                                classList={{ "border-text-strong": selected() }}
+                              >
+                                <div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
+                              </div>
+                              <div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
+                            </button>
+                          )
+                        }}
+                      </For>
+                    </div>
+                  </div>
+                </Show>
+
+                <Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
+                  <div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
+                    <Show when={selectedInstalled()?.version === 1}>
+                      <div class="text-12-regular text-text-warning-base">
+                        {language.t("wsl.onboarding.wsl2Required")}
+                      </div>
+                    </Show>
+                    <Show when={distroUnavailableMessage()}>
+                      {(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
+                    </Show>
+                    <Show when={distroMissingTools()}>
+                      <div class="text-12-regular text-text-warning-base">
+                        {language.t("wsl.onboarding.toolsRequired")}
+                      </div>
+                    </Show>
+                  </div>
+                </Show>
+
+                <div class="flex items-center gap-2">
+                  <Button
+                    variant="secondary"
+                    size="large"
+                    disabled={busy() || !selectedInstalled()}
+                    onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
+                  >
+                    {language.t("wsl.onboarding.openTerminal")}
+                  </Button>
+                  <Button
+                    variant="ghost"
+                    size="large"
+                    disabled={busy() || !selectedDistro()}
+                    onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
+                  >
+                    {language.t("wsl.onboarding.refresh")}
+                  </Button>
+                </div>
+
+                <div class="flex items-center justify-end">
+                  <Button
+                    variant="secondary"
+                    size="large"
+                    disabled={busy() || !selectedDistro() || !distroReady()}
+                    onClick={openOpencodeStep}
+                  >
+                    {language.t("wsl.onboarding.next")}
+                  </Button>
+                </div>
+              </div>
+            </Match>
+
+            <Match when={activeStep() === "opencode"}>
+              <div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
+                <div class="flex items-center justify-between gap-3">
+                  <div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
+                  <div class="flex items-center gap-2">
+                    <Show when={selectedDistro()}>
+                      <Button
+                        variant="ghost"
+                        size="large"
+                        disabled={busy()}
+                        onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
+                      >
+                        {language.t("wsl.onboarding.refresh")}
+                      </Button>
+                    </Show>
+                    <Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
+                      <Button
+                        variant="secondary"
+                        size="large"
+                        disabled={busy()}
+                        onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
+                      >
+                        <Show when={installingOpencode()}>
+                          <Spinner class="size-4 shrink-0" />
+                        </Show>
+                        {opencodeCheck()?.resolvedPath
+                          ? language.t("wsl.onboarding.updateOpencode")
+                          : language.t("wsl.onboarding.installOpencode")}
+                      </Button>
+                    </Show>
+                  </div>
+                </div>
+                <div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
+                <Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
+                  {(check) => (
+                    <div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
+                      <div class="text-12-regular text-text-weak">
+                        {language.t("wsl.onboarding.path", {
+                          path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
+                        })}
+                      </div>
+                      <div class="text-12-regular text-text-weak">
+                        {language.t("wsl.onboarding.version", {
+                          version: check().version ?? language.t("wsl.onboarding.unknown"),
+                        })}
+                        <Show when={check().expectedVersion}>
+                          {(expected) => (
+                            <span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
+                          )}
+                        </Show>
+                      </div>
+                      <div class="text-12-regular text-text-warning-base">
+                        {language.t("wsl.onboarding.versionMismatch")}
+                      </div>
+                    </div>
+                  )}
+                </Show>
+              </div>
+            </Match>
+          </Switch>
+
+          <Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
+            <div class="flex items-center justify-end gap-2">
+              <Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
+                {language.t("common.cancel")}
+              </Button>
+              <Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
+                {store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
+              </Button>
+            </div>
+          </Show>
+        </Show>
+      </Show>
+    </div>
+  )
+}
+
+function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
+  console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
+  showToast({
+    variant: "error",
+    title: language.t("common.requestFailed"),
+    description: err instanceof Error ? err.message : String(err),
+  })
+}

+ 57 - 0
packages/app/src/wsl/settings-model.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test"
+import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
+
+describe("WSL server settings presentation", () => {
+  test("retries only settled unsuccessful runtimes", () => {
+    expect(wslRuntimeRetryable({ kind: "starting" })).toBe(false)
+    expect(wslRuntimeRetryable({ kind: "ready", url: "http://127.0.0.1:4096", username: null, password: null })).toBe(
+      false,
+    )
+    expect(wslRuntimeRetryable({ kind: "failed", message: "boom" })).toBe(true)
+    expect(wslRuntimeRetryable({ kind: "stopped" })).toBe(true)
+  })
+
+  test("offers install and update only when OpenCode needs attention", () => {
+    expect(wslOpencodeAction(undefined)).toBeUndefined()
+    expect(
+      wslOpencodeAction({
+        distro: "Debian",
+        resolvedPath: null,
+        version: null,
+        expectedVersion: "1.2.3",
+        matchesDesktop: null,
+        error: null,
+      }),
+    ).toBe("Install OpenCode")
+    expect(
+      wslOpencodeAction({
+        distro: "Debian",
+        resolvedPath: "/usr/local/bin/opencode",
+        version: "1.2.2",
+        expectedVersion: "1.2.3",
+        matchesDesktop: false,
+        error: null,
+      }),
+    ).toBe("Update OpenCode")
+    expect(
+      wslOpencodeAction({
+        distro: "Debian",
+        resolvedPath: "/usr/local/bin/opencode",
+        version: "1.2.3",
+        expectedVersion: "1.2.3",
+        matchesDesktop: true,
+        error: null,
+      }),
+    ).toBeUndefined()
+  })
+
+  test("probes the selected distro before entering the OpenCode step", async () => {
+    const calls: string[] = []
+    await enterWslOpencodeStep(
+      "Debian",
+      async (distro) => calls.push(distro),
+      (step) => calls.push(step),
+    )
+    expect(calls).toEqual(["Debian", "opencode"])
+  })
+})

+ 19 - 0
packages/app/src/wsl/settings-model.ts

@@ -0,0 +1,19 @@
+import type { WslOpencodeCheck, WslServerRuntime } from "./types"
+
+export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
+  runtime.kind === "failed" || runtime.kind === "stopped"
+
+export async function enterWslOpencodeStep(
+  distro: string,
+  probe: (distro: string) => Promise<unknown>,
+  select: (step: "opencode") => void,
+) {
+  await probe(distro)
+  select("opencode")
+}
+
+export function wslOpencodeAction(check?: WslOpencodeCheck) {
+  if (!check) return
+  if (!check.resolvedPath) return "Install OpenCode"
+  if (check.matchesDesktop === false) return "Update OpenCode"
+}

+ 167 - 0
packages/app/src/wsl/settings.tsx

@@ -0,0 +1,167 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Tag } from "@opencode-ai/ui/v2/badge-v2"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { useMutation } from "@tanstack/solid-query"
+import fuzzysort from "fuzzysort"
+import { type Accessor, For, Show, createMemo } from "solid-js"
+import type { useServerManagementController } from "@/components/dialog-select-server"
+import { ServerHealthIndicator } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { ServerConnection } from "@/context/server"
+import { showToast } from "@/utils/toast"
+import { DialogAddWslServer } from "./dialog-add-server"
+import { useWslServers } from "./context"
+import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
+
+type Controller = ReturnType<typeof useServerManagementController>
+
+export function isWslServer(server: ServerConnection.Any) {
+  return server.type === "sidecar" && server.variant === "wsl"
+}
+
+export function WslAddServerButton() {
+  const platform = usePlatform()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const openAdd = () => {
+    dialog.push(() => (
+      <Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
+        <DialogAddWslServer />
+      </Dialog>
+    ))
+  }
+  return (
+    <Show when={platform.wslServers}>
+      <ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
+        {language.t("wsl.server.addShort")}
+      </ButtonV2>
+    </Show>
+  )
+}
+
+export function useFilteredWslServers(filter: Accessor<string>) {
+  const wsl = useWslServers()
+  return createMemo(() => {
+    const servers = wsl.data?.servers ?? []
+    const query = filter().trim()
+    if (!query) return servers
+    return fuzzysort
+      .go(query, servers, { keys: [(item) => item.config.distro, (item) => item.config.id] })
+      .map((x) => x.obj)
+  })
+}
+
+export function WslServerSettings(props: {
+  controller: Controller
+  servers: ReturnType<typeof useFilteredWslServers>
+}) {
+  const platform = usePlatform()
+  const language = useLanguage()
+  const wsl = useWslServers()
+  const api = platform.wslServers
+
+  const request = useMutation(() => ({
+    mutationFn: (action: () => Promise<unknown>) => action(),
+    onError: (error) =>
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: error instanceof Error ? error.message : String(error),
+      }),
+  }))
+
+  const remove = (key: ServerConnection.Key) => {
+    if (!api) return
+    request.mutate(async () => {
+      await api.removeServer(key)
+      await props.controller.handleRemove(key)
+    })
+  }
+
+  return (
+    <Show when={api}>
+      <For each={props.servers()}>
+        {(item) => {
+          const key = ServerConnection.Key.make(item.config.id)
+          const check = () => wsl.data?.opencodeChecks[item.config.distro]
+          const opencodeAction = () => wslOpencodeAction(check())
+          const busy = () => wsl.data?.job?.kind === "install-opencode" && wsl.data.job.distro === item.config.distro
+          return (
+            <div class="settings-v2-servers-row">
+              <div class="settings-v2-servers-lead">
+                <ServerHealthIndicator health={props.controller.status()[key]} />
+                <div class="settings-v2-servers-copy">
+                  <span class="flex min-w-0 items-center gap-1">
+                    <span class="settings-v2-servers-name">{item.config.distro}</span>
+                    <span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
+                      {language.t("wsl.server.label")}
+                    </span>
+                  </span>
+                  <span class="settings-v2-servers-meta">
+                    <Show when={check()?.version}>{(version) => `v${version()}`}</Show>
+                  </span>
+                </div>
+              </div>
+              <div class="settings-v2-servers-actions">
+                <Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
+                  <Tag>{language.t("dialog.server.status.default")}</Tag>
+                </Show>
+                <Show when={opencodeAction()}>
+                  {(label) => (
+                    <ButtonV2
+                      size="small"
+                      disabled={busy() || request.isPending}
+                      onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))}
+                    >
+                      {busy() ? language.t("wsl.server.updating") : label()}
+                    </ButtonV2>
+                  )}
+                </Show>
+                <MenuV2 gutter={4} modal={false} placement="bottom-end">
+                  <MenuV2.Trigger
+                    as={IconButtonV2}
+                    variant="ghost-muted"
+                    size="small"
+                    icon={<IconV2 name="outline-dots" />}
+                    aria-label={language.t("common.moreOptions")}
+                  />
+                  <MenuV2.Portal>
+                    <MenuV2.Content>
+                      <MenuV2.Group>
+                        <MenuV2.GroupLabel>{language.t("wsl.server.menu.label")}</MenuV2.GroupLabel>
+                        <Show when={wslRuntimeRetryable(item.runtime)}>
+                          <MenuV2.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
+                            {language.t("wsl.server.retryStart")}
+                          </MenuV2.Item>
+                        </Show>
+                        <Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
+                          <MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
+                            {language.t("dialog.server.menu.default")}
+                          </MenuV2.Item>
+                        </Show>
+                        <Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
+                          <MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
+                            {language.t("dialog.server.menu.defaultRemove")}
+                          </MenuV2.Item>
+                        </Show>
+                        <MenuV2.Separator />
+                        <MenuV2.Item onSelect={() => remove(key)}>
+                          {language.t("dialog.server.menu.delete")}
+                        </MenuV2.Item>
+                      </MenuV2.Group>
+                    </MenuV2.Content>
+                  </MenuV2.Portal>
+                </MenuV2>
+              </div>
+            </div>
+          )
+        }}
+      </For>
+    </Show>
+  )
+}

+ 87 - 0
packages/app/src/wsl/types.ts

@@ -0,0 +1,87 @@
+export type WslRuntimeCheck = {
+  available: boolean
+  version: string | null
+  error: string | null
+}
+
+export type WslInstalledDistro = {
+  name: string
+  version: number | null
+  isDefault: boolean
+}
+
+export type WslOnlineDistro = {
+  name: string
+  label: string
+}
+
+export type WslDistroProbe = {
+  name: string
+  canExecute: boolean
+  hasBash: boolean
+  hasCurl: boolean
+  error: string | null
+}
+
+export type WslOpencodeCheck = {
+  distro: string
+  resolvedPath: string | null
+  version: string | null
+  expectedVersion: string | null
+  matchesDesktop: boolean | null
+  error: string | null
+}
+
+export type WslServerConfig = {
+  id: string
+  distro: string
+}
+
+export type WslServerRuntime =
+  | { kind: "starting" }
+  | { kind: "ready"; url: string; username: string | null; password: string | null }
+  | { kind: "failed"; message: string }
+  | { kind: "stopped" }
+
+export type WslServerItem = {
+  config: WslServerConfig
+  runtime: WslServerRuntime
+}
+
+export type WslJob =
+  | { kind: "runtime"; startedAt: number }
+  | { kind: "distros"; startedAt: number }
+  | { kind: "install-wsl"; startedAt: number }
+  | { kind: "install-distro"; distro: string; startedAt: number }
+  | { kind: "probe-distro"; distro: string; startedAt: number }
+  | { kind: "probe-opencode"; distro: string; startedAt: number }
+  | { kind: "install-opencode"; distro: string; startedAt: number }
+
+export type WslServersState = {
+  runtime: WslRuntimeCheck | null
+  installed: WslInstalledDistro[]
+  online: WslOnlineDistro[]
+  distroProbes: Record<string, WslDistroProbe>
+  opencodeChecks: Record<string, WslOpencodeCheck>
+  pendingRestart: boolean
+  servers: WslServerItem[]
+  job: WslJob | null
+}
+
+export type WslServersEvent = { type: "state"; state: WslServersState }
+
+export type WslServersPlatform = {
+  getState(): Promise<WslServersState>
+  subscribe(cb: (event: WslServersEvent) => void): () => void
+  probeRuntime(): Promise<void>
+  refreshDistros(): Promise<void>
+  installWsl(): Promise<void>
+  installDistro(name: string): Promise<void>
+  probeDistro(name: string): Promise<void>
+  probeOpencode(name: string): Promise<void>
+  installOpencode(name: string): Promise<void>
+  openTerminal(name: string): Promise<void>
+  addServer(distro: string): Promise<WslServerConfig>
+  removeServer(id: string): Promise<void>
+  startServer(id: string): Promise<void>
+}

+ 1 - 20
packages/desktop/src/main/apps.ts

@@ -1,4 +1,4 @@
-import { execFile, execFileSync } from "node:child_process"
+import { execFile } from "node:child_process"
 import { access, readFile, readdir } from "node:fs/promises"
 import { dirname, extname, join } from "node:path"
 import util from "node:util"
@@ -21,25 +21,6 @@ export function resolveAppPath(appName: string) {
   return resolveWindowsAppPath(appName)
 }
 
-export function wslPath(path: string, mode: "windows" | "linux" | null): string {
-  if (process.platform !== "win32") return path
-
-  const flag = mode === "windows" ? "-w" : "-u"
-  try {
-    if (path.startsWith("~")) {
-      const suffix = path.slice(1)
-      const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
-      const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
-      return output.toString().trim()
-    }
-
-    const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
-    return output.toString().trim()
-  } catch (error) {
-    throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
-  }
-}
-
 async function checkMacosApp(appName: string) {
   const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
 

+ 1 - 1
packages/desktop/src/main/constants.ts

@@ -6,6 +6,6 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
 
 export const SETTINGS_STORE = "opencode.settings"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
-export const WSL_ENABLED_KEY = "wslEnabled"
+export const WSL_SERVERS_KEY = "wslServers"
 export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
 export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

+ 43 - 23
packages/desktop/src/main/index.ts

@@ -8,10 +8,11 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls"
 import type { Event } from "electron"
 import { app, BrowserWindow } from "electron"
 
+import { Deferred, Effect, Fiber } from "effect"
 import contextMenu from "electron-context-menu"
 
-import type { ServerReadyData, WslConfig } from "../preload/types"
-import { checkAppExists, resolveAppPath, wslPath } from "./apps"
+import type { ServerReadyData } from "../preload/types"
+import { checkAppExists, resolveAppPath } from "./apps"
 import { CHANNEL, UPDATER_ENABLED } from "./constants"
 import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
 import { forwardInitializationFailure } from "./initialization"
@@ -20,13 +21,12 @@ import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
 import {
   getDefaultServerUrl,
-  getWslConfig,
   preferAppEnv,
   setDefaultServerUrl,
-  setWslConfig,
   spawnLocalServer,
   type SidecarListener,
 } from "./server"
+import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
 import {
   createMainWindow,
   registerRendererProtocol,
@@ -34,9 +34,10 @@ import {
   setBackgroundColor,
   setDockIcon,
 } from "./windows"
+import { createWslServersController } from "./wsl/servers"
+import { registerWslIpcHandlers } from "./wsl/ipc"
+import { spawnWslSidecar } from "./wsl/sidecar"
 import { migrate } from "./migrate"
-import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
-import { Deferred, Effect, Fiber } from "effect"
 
 const APP_NAMES: Record<string, string> = {
   dev: "OpenCode Dev",
@@ -135,6 +136,30 @@ const main = Effect.gen(function* () {
   logger = initLogging()
   initCrashReporter()
 
+  const wslServers = createWslServersController(
+    app.getVersion(),
+    async (distro) => {
+      logger.log("spawning wsl sidecar", { distro })
+      return spawnWslSidecar(distro, {
+        onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
+      })
+    },
+    {
+      log: (message, meta) => logger.log(message, meta),
+      error: (message, meta) => logger.error(message, meta),
+    },
+  )
+  const stopSidecars = async () => {
+    await killSidecar()
+    wslServers.stopAll()
+  }
+  const relaunch = () => {
+    void stopSidecars().finally(() => {
+      app.relaunch()
+      app.exit(0)
+    })
+  }
+
   try {
     setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
   } catch (error) {
@@ -180,11 +205,11 @@ const main = Effect.gen(function* () {
   })
 
   app.on("before-quit", () => {
-    void killSidecar()
+    void stopSidecars()
   })
 
   app.on("will-quit", () => {
-    void killSidecar()
+    void stopSidecars()
   })
 
   app.on("child-process-gone", (_event, details) => {
@@ -196,15 +221,12 @@ const main = Effect.gen(function* () {
   })
 
   setRelaunchHandler(() => {
-    void killSidecar().finally(() => {
-      app.relaunch()
-      app.exit(0)
-    })
+    relaunch()
   })
 
   for (const signal of ["SIGINT", "SIGTERM"] as const) {
     process.on(signal, () => {
-      void killSidecar().finally(() => app.exit(0))
+      void stopSidecars().finally(() => app.exit(0))
     })
   }
 
@@ -212,6 +234,7 @@ const main = Effect.gen(function* () {
 
   registerIpcHandlers({
     killSidecar: () => killSidecar(),
+    relaunch,
     awaitInitialization: Effect.fnUntraced(
       function* () {
         logger.log("awaiting server ready")
@@ -225,21 +248,19 @@ const main = Effect.gen(function* () {
     consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
     getDefaultServerUrl: () => getDefaultServerUrl(),
     setDefaultServerUrl: (url) => setDefaultServerUrl(url),
-    getWslConfig: () => Promise.resolve(getWslConfig()),
-    setWslConfig: (config: WslConfig) => setWslConfig(config),
     getDisplayBackend: async () => null,
     setDisplayBackend: async () => undefined,
     parseMarkdown: async (markdown) => parseMarkdown(markdown),
     checkAppExists: (appName) => checkAppExists(appName),
-    wslPath: async (path, mode) => wslPath(path, mode),
     resolveAppPath: async (appName) => resolveAppPath(appName),
-    runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
+    runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
     checkUpdate: async () => checkUpdate(),
-    installUpdate: async () => installUpdate(killSidecar),
+    installUpdate: async () => installUpdate(stopSidecars),
     setBackgroundColor: (color) => setBackgroundColor(color),
     exportDebugLogs: () => exportDebugLogs(),
     recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
   })
+  registerWslIpcHandlers(wslServers)
 
   yield* Effect.promise(() => app.whenReady())
 
@@ -305,6 +326,8 @@ const main = Effect.gen(function* () {
       password,
     })
 
+    void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
+
     yield* Effect.promise(() => health.wait).pipe(
       Effect.timeout("30 seconds"),
       Effect.catch((e) =>
@@ -327,13 +350,10 @@ const main = Effect.gen(function* () {
         if (win) sendMenuCommand(win, id)
       },
       checkForUpdates: () => {
-        void checkForUpdates(true, killSidecar)
+        void checkForUpdates(true, stopSidecars)
       },
       relaunch: () => {
-        void killSidecar().finally(() => {
-          app.relaunch()
-          app.exit(0)
-        })
+        relaunch()
       },
     })
   }

+ 4 - 12
packages/desktop/src/main/ipc.ts

@@ -1,9 +1,9 @@
 import { execFile } from "node:child_process"
-import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
+import { BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
 import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
 
-import type { FatalRendererError, ServerReadyData, TitlebarTheme, WindowConfig, WslConfig } from "../preload/types"
+import type { FatalRendererError, ServerReadyData, TitlebarTheme, WindowConfig } from "../preload/types"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { getStore } from "./store"
 import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
@@ -15,18 +15,16 @@ const pickerFilters = (ext?: string[]) => {
 
 type Deps = {
   killSidecar: () => Promise<void> | void
+  relaunch: () => void
   awaitInitialization: () => Promise<ServerReadyData>
   getWindowConfig: () => Promise<WindowConfig> | WindowConfig
   consumeInitialDeepLinks: () => Promise<string[]> | string[]
   getDefaultServerUrl: () => Promise<string | null> | string | null
   setDefaultServerUrl: (url: string | null) => Promise<void> | void
-  getWslConfig: () => Promise<WslConfig>
-  setWslConfig: (config: WslConfig) => Promise<void> | void
   getDisplayBackend: () => Promise<string | null>
   setDisplayBackend: (backend: string | null) => Promise<void> | void
   parseMarkdown: (markdown: string) => Promise<string> | string
   checkAppExists: (appName: string) => Promise<boolean> | boolean
-  wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
   resolveAppPath: (appName: string) => Promise<string | null>
   runUpdater: (alertOnFail: boolean) => Promise<void> | void
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
@@ -45,17 +43,12 @@ export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
     deps.setDefaultServerUrl(url),
   )
-  ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
-  ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
   ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
   ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
     deps.setDisplayBackend(backend),
   )
   ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
   ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
-  ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
-    deps.wslPath(path, mode),
-  )
   ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
   ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
   ipcMain.handle("check-update", () => deps.checkUpdate())
@@ -178,8 +171,7 @@ export function registerIpcHandlers(deps: Deps) {
   })
 
   ipcMain.on("relaunch", () => {
-    app.relaunch()
-    app.exit(0)
+    deps.relaunch()
   })
 
   ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())

+ 1 - 12
packages/desktop/src/main/server.ts

@@ -2,12 +2,10 @@ import { dirname, join } from "node:path"
 import { fileURLToPath } from "node:url"
 import { app, utilityProcess } from "electron"
 import type { Details } from "electron"
-import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { DEFAULT_SERVER_URL_KEY } from "./constants"
 import { getUserShell, loadShellEnv } from "./shell-env"
 import { getStore } from "./store"
 
-export type WslConfig = { enabled: boolean }
-
 export type HealthCheck = { wait: Promise<void> }
 
 type SidecarMessage =
@@ -42,15 +40,6 @@ export function setDefaultServerUrl(url: string | null) {
   getStore().delete(DEFAULT_SERVER_URL_KEY)
 }
 
-export function getWslConfig(): WslConfig {
-  const value = getStore().get(WSL_ENABLED_KEY)
-  return { enabled: typeof value === "boolean" ? value : false }
-}
-
-export function setWslConfig(config: WslConfig) {
-  getStore().set(WSL_ENABLED_KEY, config.enabled)
-}
-
 export function preferAppEnv(userDataPath: string) {
   const shell = process.platform === "win32" ? null : getUserShell()
   Object.assign(process.env, {

+ 64 - 0
packages/desktop/src/main/wsl/ipc.ts

@@ -0,0 +1,64 @@
+import { app, ipcMain } from "electron"
+import type { IpcMainInvokeEvent } from "electron"
+import type { WslServersController } from "./servers"
+import { requireWslIpcString } from "./policy"
+
+export function registerWslIpcHandlers(controller: WslServersController) {
+  const subscriptions = new Map<number, () => void>()
+  const unsubscribe = (id: number) => {
+    const off = subscriptions.get(id)
+    if (!off) return
+    off()
+    subscriptions.delete(id)
+  }
+
+  app.once("will-quit", () => {
+    subscriptions.forEach((off) => off())
+    subscriptions.clear()
+  })
+
+  ipcMain.handle("wsl-servers-subscribe", (event) => {
+    const id = event.sender.id
+    if (subscriptions.has(id)) return
+    subscriptions.set(
+      id,
+      controller.subscribe((payload) => {
+        if (event.sender.isDestroyed()) {
+          unsubscribe(id)
+          return
+        }
+        event.sender.send("wsl-servers-event", payload)
+      }),
+    )
+    event.sender.once("destroyed", () => unsubscribe(id))
+  })
+  ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id))
+  ipcMain.handle("wsl-servers-get-state", () => controller.getState())
+  ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime())
+  ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros())
+  ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl())
+  ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
+    controller.installDistro(requireWslIpcString("distro", name)),
+  )
+  ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
+    controller.probeDistro(requireWslIpcString("distro", name)),
+  )
+  ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
+    controller.probeOpencode(requireWslIpcString("distro", name)),
+  )
+  ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
+    controller.installOpencode(requireWslIpcString("distro", name)),
+  )
+  ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
+    controller.openTerminal(requireWslIpcString("distro", name)),
+  )
+  ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
+    controller.addServer(requireWslIpcString("distro", distro)),
+  )
+  ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
+    controller.removeServer(requireWslIpcString("server id", id)),
+  )
+  ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
+    controller.startServer(requireWslIpcString("server id", id)),
+  )
+}

+ 26 - 0
packages/desktop/src/main/wsl/policy.ts

@@ -0,0 +1,26 @@
+import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
+
+export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
+  return servers.find((item) => item.config.distro === distro)?.config.id
+}
+
+export function clearWslDistroState(
+  distroProbes: Record<string, WslDistroProbe>,
+  opencodeChecks: Record<string, WslOpencodeCheck>,
+  distro: string,
+) {
+  const nextDistroProbes = { ...distroProbes }
+  const nextOpencodeChecks = { ...opencodeChecks }
+  delete nextDistroProbes[distro]
+  delete nextOpencodeChecks[distro]
+  return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
+}
+
+export function wslTerminalArgs(distro?: string | null) {
+  return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
+}
+
+export function requireWslIpcString(name: string, value: unknown) {
+  if (typeof value === "string" && value.length > 0) return value
+  throw new Error(`Invalid ${name}`)
+}

+ 400 - 0
packages/desktop/src/main/wsl/runtime.ts

@@ -0,0 +1,400 @@
+import { spawn } from "node:child_process"
+import { existsSync } from "node:fs"
+import { join } from "node:path"
+import * as pty from "@lydell/node-pty"
+import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
+import { wslTerminalArgs } from "./policy"
+
+export type WslCommandLine = {
+  stream: "stdout" | "stderr"
+  text: string
+}
+
+export type WslCommandResult = {
+  code: number | null
+  signal: NodeJS.Signals | null
+  stdout: string
+  stderr: string
+}
+
+export type RunWslOptions = {
+  signal?: AbortSignal
+  /**
+   * Ceiling on how long we wait for the child process to exit. When the
+   * LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
+   * pending first-run prompt), `wsl.exe` never returns and any command
+   * that doesn't specify a timeout hangs the entire startup flow. Default
+   * is 20s — enough for slow cold-starts, short enough to fail fast on
+   * a wedge. Callers can override for longer-running jobs.
+   */
+  timeoutMs?: number
+}
+
+const DEFAULT_WSL_TIMEOUT_MS = 20_000
+const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
+
+export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
+  return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
+}
+
+export function runWsl(args: string[], opts: RunWslOptions = {}) {
+  return runCommand("wsl", args, opts)
+}
+
+function runPowerShell(command: string, opts: RunWslOptions = {}) {
+  return runCommand(
+    "powershell.exe",
+    ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
+    opts,
+  )
+}
+
+function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
+  return new Promise<WslCommandResult>((resolve, reject) => {
+    const child = spawn(command, args, {
+      stdio: ["ignore", "pipe", "pipe"],
+      windowsHide: true,
+      signal: opts.signal,
+    })
+
+    // Guard every wsl.exe invocation with a timeout. When the distro or
+    // the LXSS service is wedged (Ubuntu first-run state, Windows update
+    // pending, etc.) wsl.exe produces no output and never exits; without
+    // this the whole sidecar spawn flow stalls the app forever.
+    const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
+    const timeoutId = setTimeout(() => {
+      try {
+        child.kill()
+      } catch {
+        /* ignore */
+      }
+      reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
+    }, timeoutMs)
+
+    let stdout = ""
+    let stderr = ""
+    const stdoutDecoder = createOutputDecoder()
+    const stderrDecoder = createOutputDecoder()
+
+    const append = (stream: WslCommandLine["stream"], chunk: string) => {
+      if (!chunk) return
+      if (stream === "stdout") {
+        stdout += chunk
+        return
+      }
+      stderr += chunk
+    }
+
+    child.stdout.on("data", (chunk: Buffer) => {
+      append("stdout", stdoutDecoder.decode(chunk))
+    })
+    child.stdout.on("end", () => {
+      append("stdout", stdoutDecoder.flush())
+    })
+
+    child.stderr.on("data", (chunk: Buffer) => {
+      append("stderr", stderrDecoder.decode(chunk))
+    })
+    child.stderr.on("end", () => {
+      append("stderr", stderrDecoder.flush())
+    })
+
+    child.once("error", (error) => {
+      clearTimeout(timeoutId)
+      reject(error)
+    })
+    child.once("close", (code, signal) => {
+      clearTimeout(timeoutId)
+      resolve({ code, signal, stdout, stderr })
+    })
+  })
+}
+
+function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
+  return new Promise<WslCommandResult>((resolve, reject) => {
+    const child = pty.spawn(command, args, {
+      name: "xterm-color",
+      cols: 80,
+      rows: 24,
+      cwd: process.cwd(),
+      env: process.env,
+      useConpty: true,
+    })
+
+    let settled = false
+    let stdout = ""
+
+    const cleanup = () => {
+      clearTimeout(timeoutId)
+      abortCleanup?.()
+    }
+
+    const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
+    const timeoutId = setTimeout(() => {
+      try {
+        child.kill()
+      } catch {
+        /* ignore */
+      }
+      if (settled) return
+      settled = true
+      cleanup()
+      reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
+    }, timeoutMs)
+
+    const abortHandler = () => {
+      try {
+        child.kill()
+      } catch {
+        /* ignore */
+      }
+      if (settled) return
+      settled = true
+      cleanup()
+      reject(new DOMException("Aborted", "AbortError"))
+    }
+    const abortCleanup = opts.signal
+      ? (() => {
+          opts.signal?.addEventListener("abort", abortHandler, { once: true })
+          return () => opts.signal?.removeEventListener("abort", abortHandler)
+        })()
+      : undefined
+
+    child.onData((data: string) => {
+      stdout += data
+    })
+    child.onExit((event: { exitCode: number }) => {
+      if (settled) return
+      settled = true
+      cleanup()
+      resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
+    })
+  })
+}
+
+function createOutputDecoder() {
+  let decoder: TextDecoder | undefined
+  return {
+    decode(chunk: Buffer) {
+      decoder ??= new TextDecoder(detectOutputEncoding(chunk))
+      return decoder.decode(chunk, { stream: true })
+    },
+    flush() {
+      return decoder?.decode() ?? ""
+    },
+  }
+}
+
+function detectOutputEncoding(chunk: Uint8Array) {
+  if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
+  const pairs = Math.floor(chunk.length / 2)
+  if (pairs < 2) return "utf-8"
+  const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
+  const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
+  return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
+}
+
+export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
+  return runWsl(wslArgs(args, distro), opts)
+}
+
+export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
+  return runWslInDistro(["sh", "-lc", script], distro, opts)
+}
+
+export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
+  const version = await runWsl(["--version"], opts).catch((error) => ({
+    code: 1,
+    signal: null,
+    stdout: "",
+    stderr: error instanceof Error ? error.message : String(error),
+  }))
+
+  if (version.code !== 0) {
+    return {
+      available: false,
+      version: null,
+      error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
+    }
+  }
+
+  return {
+    available: true,
+    version: firstLine(version.stdout),
+    error: null,
+  }
+}
+
+export async function listInstalledWslDistros(opts?: RunWslOptions) {
+  const result = await runWsl(["--list", "--verbose"], opts)
+  if (result.code !== 0) {
+    throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
+  }
+  return parseInstalledDistros(result.stdout)
+}
+
+export async function listOnlineWslDistros(opts?: RunWslOptions) {
+  const result = await runWsl(["--list", "--online"], opts)
+  if (result.code !== 0) {
+    throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
+  }
+  return parseOnlineDistros(result.stdout)
+}
+
+export async function installWslRuntimeElevated(opts?: RunWslOptions) {
+  const script = [
+    "$ErrorActionPreference = 'Stop'",
+    "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
+    "if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
+  ].join("; ")
+  return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
+}
+
+export async function installWslDistro(name: string, opts?: RunWslOptions) {
+  return runInteractiveCommand(
+    resolveSystem32Command("wsl.exe"),
+    ["--install", "-d", name, "--web-download", "--no-launch"],
+    withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
+    DEFAULT_WSL_INSTALL_TIMEOUT_MS,
+  )
+}
+
+export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
+  return runInteractiveCommand(
+    resolveSystem32Command("wsl.exe"),
+    wslArgs(
+      ["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
+      distro,
+    ),
+    withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
+    DEFAULT_WSL_INSTALL_TIMEOUT_MS,
+  )
+}
+
+export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
+  const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
+    code: 1,
+    signal: null,
+    stdout: "",
+    stderr: error instanceof Error ? error.message : String(error),
+  }))
+  if (executable.code !== 0) {
+    return {
+      name,
+      canExecute: false,
+      hasBash: false,
+      hasCurl: false,
+      error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
+    }
+  }
+
+  const [bash, curl] = await Promise.all([
+    runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
+    runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
+  ])
+
+  return {
+    name,
+    canExecute: true,
+    hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
+    hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
+    error: null,
+  }
+}
+
+export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
+  return firstLine(
+    (
+      await runWslSh(
+        'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
+        distro,
+        opts,
+      )
+    ).stdout,
+  )
+}
+
+export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
+  const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
+  return firstLine(result.stdout)
+}
+
+export function openWslTerminal(distro?: string | null) {
+  return new Promise<void>((resolve, reject) => {
+    const child = spawn("cmd.exe", wslTerminalArgs(distro), {
+      detached: true,
+      stdio: "ignore",
+      windowsHide: true,
+    })
+    child.once("error", reject)
+    child.once("spawn", () => {
+      child.unref()
+      resolve()
+    })
+  })
+}
+
+function parseInstalledDistros(output: string) {
+  return output.split(/\r?\n/g).flatMap((line) => {
+    const trimmed = line.trim()
+    if (!trimmed) return []
+    const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
+    if (!match) return []
+    const [, marker, name, version] = match
+    if (!name || /^name$/i.test(name)) return []
+    return [
+      {
+        name: name.trim(),
+        version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
+        isDefault: marker === "*",
+      } satisfies WslInstalledDistro,
+    ]
+  })
+}
+
+function parseOnlineDistros(output: string) {
+  return output.split(/\r?\n/g).flatMap((line) => {
+    const trimmed = line.trim()
+    if (!trimmed) return []
+    const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
+    if (!match) return []
+    const [, name, label] = match
+    if (/^name$/i.test(name)) return []
+    return [{ name, label: label.trim() } satisfies WslOnlineDistro]
+  })
+}
+
+function firstLine(value: string) {
+  return (
+    value
+      .split(/\r?\n/g)
+      .map((line) => line.trim())
+      .find(Boolean) ?? null
+  )
+}
+
+export function summarize(value: string) {
+  return value
+    .split(/\r?\n/g)
+    .map((line) => line.trim())
+    .filter(Boolean)
+    .join("\n")
+}
+
+export function shellEscape(value: string) {
+  return `'${value.replace(/'/g, `'"'"'`)}'`
+}
+
+function resolveSystem32Command(command: string) {
+  const root = process.env.SystemRoot ?? process.env.windir
+  if (!root) return command
+  const resolved = join(root, "System32", command)
+  return existsSync(resolved) ? resolved : command
+}
+
+function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
+  return {
+    ...opts,
+    timeoutMs: opts?.timeoutMs ?? timeoutMs,
+  }
+}

+ 93 - 0
packages/desktop/src/main/wsl/servers.test.ts

@@ -0,0 +1,93 @@
+import { expect, test } from "bun:test"
+import { clearWslDistroState, requireWslIpcString, wslServerIdToRestart, wslTerminalArgs } from "./policy"
+import {
+  expectOpencodeVersion,
+  pendingRestartAfterWslInstall,
+  pollWslHealth,
+  wslServerIdsToStartOnInitialize,
+} from "./startup"
+
+test("starts every configured WSL server on initialization", () => {
+  expect(
+    wslServerIdsToStartOnInitialize([
+      { id: "wsl:Debian", distro: "Debian" },
+      { id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
+    ]),
+  ).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
+})
+
+test("rejects an update that did not install the desktop version", () => {
+  expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
+  expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
+    "OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
+  )
+})
+
+test("restarts an existing distro server after updating OpenCode", () => {
+  expect(
+    wslServerIdToRestart(
+      [
+        {
+          config: { id: "wsl:Debian", distro: "Debian" },
+          runtime: { kind: "ready", url: "", username: null, password: null },
+        },
+      ],
+      "Debian",
+    ),
+  ).toBe("wsl:Debian")
+  expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
+})
+
+test("clears cached distro probes when removing a WSL server", () => {
+  expect(
+    clearWslDistroState(
+      { Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
+      {
+        Debian: {
+          distro: "Debian",
+          resolvedPath: "/home/luke/.opencode/bin/opencode",
+          version: "1.16.2",
+          expectedVersion: "1.16.2",
+          matchesDesktop: true,
+          error: null,
+        },
+      },
+      "Debian",
+    ),
+  ).toEqual({ distroProbes: {}, opencodeChecks: {} })
+})
+
+test("opens terminals for distro names containing spaces", () => {
+  expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
+})
+
+test("stops health polling when sidecar startup settles", async () => {
+  const abort = new AbortController()
+  let checks = 0
+  const polling = pollWslHealth(
+    async () => {
+      checks++
+      return false
+    },
+    abort.signal,
+    1,
+  )
+
+  await new Promise((resolve) => setTimeout(resolve, 5))
+  abort.abort()
+  await polling
+  const settled = checks
+  await new Promise((resolve) => setTimeout(resolve, 5))
+  expect(checks).toBe(settled)
+})
+
+test("validates WSL IPC identifiers at the module boundary", () => {
+  expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
+  expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
+  expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
+})
+
+test("derives a required Windows restart from the post-install runtime probe", () => {
+  expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
+  expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
+})

+ 440 - 0
packages/desktop/src/main/wsl/servers.ts

@@ -0,0 +1,440 @@
+import type {
+  WslDistroProbe,
+  WslInstalledDistro,
+  WslJob,
+  WslOnlineDistro,
+  WslOpencodeCheck,
+  WslRuntimeCheck,
+  WslServerConfig,
+  WslServerItem,
+  WslServerRuntime,
+  WslServersEvent,
+  WslServersState,
+} from "../../preload/types"
+import { WSL_SERVERS_KEY } from "../constants"
+import { getStore } from "../store"
+import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
+import { clearWslDistroState, wslServerIdToRestart } from "./policy"
+import {
+  installWslDistro,
+  installWslOpencode,
+  installWslRuntimeElevated,
+  listInstalledWslDistros,
+  listOnlineWslDistros,
+  openWslTerminal,
+  probeWslDistro,
+  probeWslRuntime,
+  readWslCommandVersion,
+  resolveWslOpencode,
+  summarize,
+} from "./runtime"
+
+type RunningSidecar = {
+  listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
+  url: string
+  username: string | null
+  password: string
+}
+
+type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
+
+type ControllerLogger = {
+  log: (message: string, meta?: unknown) => void
+  error: (message: string, meta?: unknown) => void
+}
+
+export type WslServersController = ReturnType<typeof createWslServersController>
+
+export function wslServerIdForDistro(distro: string) {
+  return `wsl:${distro}`
+}
+
+export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
+  let state: WslServersState = initialState()
+  const listeners = new Set<(event: WslServersEvent) => void>()
+  const sidecars = new Map<string, RunningSidecar>()
+  const startAttempts = new Map<string, number>()
+  let jobAbort: AbortController | undefined
+
+  const emit = () => {
+    for (const listener of listeners) listener({ type: "state", state })
+  }
+
+  const setState = (next: Partial<WslServersState>) => {
+    state = { ...state, ...next }
+    emit()
+  }
+
+  const persistServers = (servers: WslServerConfig[]) => {
+    getStore().set(WSL_SERVERS_KEY, { servers })
+  }
+
+  const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
+    const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
+    setState({ servers: next })
+  }
+
+  const beginJob = (job: WslJob): AbortController => {
+    jobAbort?.abort()
+    const abort = new AbortController()
+    jobAbort = abort
+    setState({ job })
+    return abort
+  }
+
+  const endJob = (abort: AbortController) => {
+    if (jobAbort !== abort) return
+    jobAbort = undefined
+    setState({ job: null })
+  }
+
+  const refreshFromStore = () => {
+    const persisted = readPersistedServers()
+    const items: WslServerItem[] = persisted.map((config) => {
+      const existing = state.servers.find((item) => item.config.id === config.id)
+      return {
+        config,
+        runtime: existing?.runtime ?? { kind: "stopped" },
+      }
+    })
+    setState({ servers: items })
+  }
+
+  const setRuntime = (id: string, runtime: WslServerRuntime) => {
+    updateServer(id, (item) => ({ ...item, runtime }))
+  }
+
+  const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
+    setState({
+      opencodeChecks: {
+        ...state.opencodeChecks,
+        [distro]: check,
+      },
+    })
+  }
+
+  const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
+    const resolved = await resolveWslOpencode(distro, opts)
+    const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
+    setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
+  }
+
+  const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
+    const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
+    return { installed, online }
+  }
+
+  const nextStartAttempt = (id: string) => {
+    const next = (startAttempts.get(id) ?? 0) + 1
+    startAttempts.set(id, next)
+    return next
+  }
+
+  const invalidateStartAttempt = (id: string) => {
+    startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
+  }
+
+  const isCurrentStartAttempt = (id: string, attempt: number) => {
+    return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
+  }
+
+  const startServer = async (id: string) => {
+    const item = state.servers.find((x) => x.config.id === id)
+    if (!item) return
+    const attempt = nextStartAttempt(id)
+    await stopServerInternal(id)
+    if (!isCurrentStartAttempt(id, attempt)) return
+    setRuntime(id, { kind: "starting" })
+    logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
+    try {
+      const sidecar = await spawnSidecar(item.config.distro)
+      if (!isCurrentStartAttempt(id, attempt)) {
+        try {
+          sidecar.listener.stop()
+        } catch {
+          // ignore stop errors for stale sidecars
+        }
+        return
+      }
+      sidecars.set(id, sidecar)
+      setRuntime(id, {
+        kind: "ready",
+        url: sidecar.url,
+        username: sidecar.username,
+        password: sidecar.password,
+      })
+      sidecar.listener.onExit((code, signal) => {
+        if (sidecars.get(id) !== sidecar) return
+        sidecars.delete(id)
+        const message = startupFailure(code, signal)
+        setRuntime(id, { kind: "failed", message })
+        logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
+      })
+      void refreshOpencodeCheck(item.config.distro).catch((error) => {
+        const message = error instanceof Error ? error.message : String(error)
+        logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
+      })
+      logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      if (!isCurrentStartAttempt(id, attempt)) return
+      setRuntime(id, { kind: "failed", message })
+      // Without this, an Ubuntu-style silent failure leaves no trace in
+      // main.log — the controller captures the message in its state but
+      // nothing surfaces unless the user opens the WSL servers dialog.
+      logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
+    }
+  }
+
+  const stopServerInternal = async (id: string) => {
+    const existing = sidecars.get(id)
+    if (!existing) return
+    sidecars.delete(id)
+    try {
+      existing.listener.stop()
+    } catch {
+      // ignore stop errors
+    }
+  }
+
+  const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
+    const abort = beginJob(job)
+    try {
+      const value = await runner(abort)
+      endJob(abort)
+      return value
+    } catch (error) {
+      if (error instanceof Error && error.name === "AbortError") {
+        endJob(abort)
+        return undefined
+      }
+      const err = error instanceof Error ? error : new Error(String(error))
+      endJob(abort)
+      throw err
+    }
+  }
+
+  return {
+    getState() {
+      return state
+    },
+    subscribe(listener: (event: WslServersEvent) => void) {
+      listeners.add(listener)
+      return () => listeners.delete(listener)
+    },
+
+    async initialize() {
+      refreshFromStore()
+      for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
+    },
+
+    async probeRuntime() {
+      await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
+        const runtime = await probeWslRuntime({ signal: abort.signal })
+        setState({
+          runtime,
+          pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
+        })
+      })
+    },
+
+    async refreshDistros() {
+      await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
+        setState(await refreshDistroLists({ signal: abort.signal }))
+      })
+    },
+
+    async installWsl() {
+      await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
+        const result = await installWslRuntimeElevated({ signal: abort.signal })
+        if (result.code !== 0) {
+          const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
+          throw new Error(message)
+        }
+        const runtime = await probeWslRuntime({ signal: abort.signal })
+        setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
+      })
+    },
+
+    async installDistro(name: string) {
+      await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
+        const result = await installWslDistro(name, { signal: abort.signal })
+        if (result.code !== 0) {
+          const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
+          throw new Error(message)
+        }
+        const distros = await refreshDistroLists({ signal: abort.signal })
+        const probe = await probeWslDistro(name, { signal: abort.signal })
+        setState({
+          ...distros,
+          distroProbes: { ...state.distroProbes, [name]: probe },
+        })
+      })
+    },
+
+    async probeDistro(name: string) {
+      await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
+        const probe = await probeWslDistro(name, { signal: abort.signal })
+        setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
+      })
+    },
+
+    async probeOpencode(name: string) {
+      await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
+        await refreshOpencodeCheck(name, { signal: abort.signal })
+      })
+    },
+
+    async installOpencode(name: string) {
+      await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
+        const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
+        if (result.code !== 0) {
+          throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
+        }
+        await refreshOpencodeCheck(name, { signal: abort.signal })
+        expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
+        const id = wslServerIdToRestart(state.servers, name)
+        if (id) await startServer(id)
+      })
+    },
+
+    async openTerminal(name: string) {
+      await openWslTerminal(name)
+    },
+
+    async addServer(distro: string): Promise<WslServerConfig> {
+      const id = wslServerIdForDistro(distro)
+      if (state.servers.some((item) => item.config.id === id)) {
+        throw new Error(`${distro} is already added`)
+      }
+      const config: WslServerConfig = {
+        id,
+        distro,
+      }
+      persistServers([...readPersistedServers(), config])
+      setState({
+        servers: [...state.servers, { config, runtime: { kind: "starting" } }],
+      })
+      void startServer(id)
+      return config
+    },
+
+    async removeServer(id: string) {
+      const distro = state.servers.find((item) => item.config.id === id)?.config.distro
+      invalidateStartAttempt(id)
+      await stopServerInternal(id)
+      const remaining = readPersistedServers().filter((item) => item.id !== id)
+      persistServers(remaining)
+      setState({
+        servers: state.servers.filter((item) => item.config.id !== id),
+        ...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
+      })
+    },
+
+    startServer,
+
+    stopAll() {
+      for (const item of state.servers) invalidateStartAttempt(item.config.id)
+      for (const existing of sidecars.values()) {
+        try {
+          existing.listener.stop()
+        } catch {
+          // ignore
+        }
+      }
+      sidecars.clear()
+    },
+  }
+}
+
+function initialState(): WslServersState {
+  return {
+    runtime: null,
+    installed: [],
+    online: [],
+    distroProbes: {},
+    opencodeChecks: {},
+    pendingRestart: false,
+    servers: [],
+    job: null,
+  }
+}
+
+function readPersistedServers(): WslServerConfig[] {
+  const store = getStore()
+  const existing = store.get(WSL_SERVERS_KEY)
+  if (existing && typeof existing === "object") {
+    const record = existing as { servers?: unknown }
+    const list = Array.isArray(record.servers) ? record.servers : []
+    return list.flatMap(normalizePersistedServer)
+  }
+  return []
+}
+
+function normalizePersistedServer(value: unknown): WslServerConfig[] {
+  if (!value || typeof value !== "object") return []
+  const record = value as Record<string, unknown>
+  const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
+  if (!distro) return []
+  const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
+  return [
+    {
+      id,
+      distro,
+    },
+  ]
+}
+
+function opencodeCheck(
+  distro: string,
+  resolvedPath: string | null,
+  version: string | null,
+  expectedVersion: string,
+): WslOpencodeCheck {
+  if (!resolvedPath) {
+    return {
+      distro,
+      resolvedPath: null,
+      version: null,
+      expectedVersion,
+      matchesDesktop: null,
+      error: "opencode is not installed in this distro",
+    }
+  }
+  if (!version) {
+    return {
+      distro,
+      resolvedPath,
+      version: null,
+      expectedVersion,
+      matchesDesktop: null,
+      error: "opencode is installed but could not run",
+    }
+  }
+  return {
+    distro,
+    resolvedPath,
+    version,
+    expectedVersion,
+    matchesDesktop: version === expectedVersion,
+    error: null,
+  }
+}
+
+function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
+  return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
+}
+
+// Re-export types used by callers
+export type {
+  WslInstalledDistro,
+  WslOnlineDistro,
+  WslRuntimeCheck,
+  WslDistroProbe,
+  WslOpencodeCheck,
+  WslServerConfig,
+  WslServerItem,
+  WslServerRuntime,
+  WslServersEvent,
+  WslServersState,
+}

+ 129 - 0
packages/desktop/src/main/wsl/sidecar.ts

@@ -0,0 +1,129 @@
+import { spawn } from "node:child_process"
+import { randomUUID } from "node:crypto"
+import { createServer } from "node:net"
+import { app } from "electron"
+import { checkHealth } from "../server"
+import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
+import { pollWslHealth } from "./startup"
+
+export type WslSidecar = {
+  listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
+  url: string
+  username: string | null
+  password: string
+}
+
+export async function spawnWslSidecar(
+  distro: string,
+  opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
+): Promise<WslSidecar> {
+  const opencode = await resolveWslOpencode(distro)
+  if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
+
+  const port = await allocatePort()
+  const password = randomUUID()
+  const username = "opencode"
+  const script = [
+    "set -euo pipefail",
+    'cd "$HOME" || cd /',
+    'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
+    "export PATH",
+    "export WSLENV=",
+    "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
+    "export OPENCODE_CLIENT=desktop",
+    `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
+    `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
+    'export XDG_STATE_HOME="$HOME/.local/state"',
+    `exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`,
+  ].join("\n")
+  const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
+    stdio: ["pipe", "pipe", "pipe"],
+    windowsHide: true,
+  })
+  child.stdin.end(script)
+
+  const recentOutput: string[] = []
+  const emit = (line: WslCommandLine) => {
+    if (!line.text.trim()) return
+    recentOutput.push(`[${line.stream}] ${line.text}`)
+    if (recentOutput.length > 12) recentOutput.shift()
+    opts.onLine?.(line)
+  }
+  forwardLines(child.stdout, "stdout", emit)
+  forwardLines(child.stderr, "stderr", emit)
+
+  const exit = new Promise<never>((_, reject) => {
+    child.once("error", reject)
+    child.once("exit", (code, signal) => reject(new Error(startupFailure(code, signal, recentOutput))))
+  })
+  const url = `http://127.0.0.1:${port}`
+  const startup = new AbortController()
+  const health = pollWslHealth(() => checkHealth(url, password), startup.signal)
+  const timeoutMs = opts.healthTimeoutMs ?? 30_000
+  let timeout: ReturnType<typeof setTimeout>
+  const timedOut = new Promise<never>(
+    (_, reject) =>
+      (timeout = setTimeout(
+        () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
+        timeoutMs,
+      )),
+  )
+
+  await Promise.race([health, exit, timedOut])
+    .catch((error) => {
+      child.kill()
+      throw error
+    })
+    .finally(() => {
+      clearTimeout(timeout)
+      startup.abort()
+    })
+  return {
+    listener: {
+      stop: () => child.kill(),
+      onExit: (cb) => child.once("exit", cb),
+    },
+    url,
+    username,
+    password,
+  }
+}
+
+function allocatePort() {
+  return new Promise<number>((resolve, reject) => {
+    const server = createServer()
+    server.on("error", reject)
+    server.listen(0, "127.0.0.1", () => {
+      const address = server.address()
+      if (typeof address !== "object" || !address) {
+        server.close()
+        reject(new Error("Failed to get port"))
+        return
+      }
+      server.close(() => resolve(address.port))
+    })
+  })
+}
+
+function forwardLines(
+  stream: NodeJS.ReadableStream,
+  source: WslCommandLine["stream"],
+  onLine: (line: WslCommandLine) => void,
+) {
+  let pending = ""
+  stream.setEncoding("utf8")
+  stream.on("data", (chunk: string) => {
+    pending += chunk
+    const lines = pending.split(/\r?\n/g)
+    pending = lines.pop() ?? ""
+    lines.forEach((text) => onLine({ stream: source, text }))
+  })
+  stream.on("end", () => {
+    if (pending) onLine({ stream: source, text: pending })
+  })
+}
+
+function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
+  const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
+  return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
+}

+ 31 - 0
packages/desktop/src/main/wsl/startup.ts

@@ -0,0 +1,31 @@
+export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
+  return servers.map((server) => server.id)
+}
+
+export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
+  if (installed === expected) return
+  throw new Error(
+    `OpenCode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`,
+  )
+}
+
+export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
+
+export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
+  while (!signal.aborted) {
+    if (await check()) return
+    await abortableDelay(interval, signal)
+  }
+}
+
+function abortableDelay(duration: number, signal: AbortSignal) {
+  return new Promise<void>((resolve) => {
+    const done = () => {
+      clearTimeout(timeout)
+      signal.removeEventListener("abort", done)
+      resolve()
+    }
+    const timeout = setTimeout(done, duration)
+    signal.addEventListener("abort", done, { once: true })
+  })
+}

+ 24 - 4
packages/desktop/src/preload/index.ts

@@ -1,21 +1,41 @@
 import { contextBridge, ipcRenderer } from "electron"
-import type { ElectronAPI } from "./types"
+import type { ElectronAPI, WslServersEvent } from "./types"
 
 const api: ElectronAPI = {
   killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
   installCli: () => ipcRenderer.invoke("install-cli"),
   awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
+  wslServers: {
+    getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
+    subscribe: (cb) => {
+      const handler = (_: unknown, event: WslServersEvent) => cb(event)
+      ipcRenderer.on("wsl-servers-event", handler)
+      void ipcRenderer.invoke("wsl-servers-subscribe")
+      return () => {
+        ipcRenderer.removeListener("wsl-servers-event", handler)
+        void ipcRenderer.invoke("wsl-servers-unsubscribe")
+      }
+    },
+    probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
+    refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
+    installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
+    installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
+    probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
+    probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
+    installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
+    openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
+    addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
+    removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
+    startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
+  },
   getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
   consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
   getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
   setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
-  getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
-  setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
   getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
   setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
   parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
   checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
-  wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
   resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
   storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
   storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),

+ 16 - 4
packages/desktop/src/preload/types.ts

@@ -1,4 +1,18 @@
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
+import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
+export type {
+  WslDistroProbe,
+  WslInstalledDistro,
+  WslJob,
+  WslOnlineDistro,
+  WslOpencodeCheck,
+  WslRuntimeCheck,
+  WslServerConfig,
+  WslServerItem,
+  WslServerRuntime,
+  WslServersEvent,
+  WslServersState,
+} from "@opencode-ai/app/wsl/types"
 
 export type ServerReadyData = {
   url: string
@@ -6,7 +20,7 @@ export type ServerReadyData = {
   password: string | null
 }
 
-export type WslConfig = { enabled: boolean }
+export type WslServersAPI = WslServersPlatform
 
 export type LinuxDisplayBackend = "wayland" | "auto"
 export type TitlebarTheme = {
@@ -28,17 +42,15 @@ export type ElectronAPI = {
   killSidecar: () => Promise<void>
   installCli: () => Promise<string>
   awaitInitialization: () => Promise<ServerReadyData>
+  wslServers: WslServersAPI
   getWindowConfig: () => Promise<WindowConfig>
   consumeInitialDeepLinks: () => Promise<string[]>
   getDefaultServerUrl: () => Promise<string | null>
   setDefaultServerUrl: (url: string | null) => Promise<void>
-  getWslConfig: () => Promise<WslConfig>
-  setWslConfig: (config: WslConfig) => Promise<void>
   getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
   setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
   parseMarkdownCommand: (markdown: string) => Promise<string>
   checkAppExists: (appName: string) => Promise<boolean>
-  wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
   resolveAppPath: (appName: string) => Promise<string | null>
   storeGet: (name: string, key: string) => Promise<string | null>
   storeSet: (name: string, key: string, value: string) => Promise<void>

+ 60 - 87
packages/desktop/src/renderer/index.tsx

@@ -13,17 +13,20 @@ import {
   PlatformProvider,
   ServerConnection,
   useCommand,
+  useWslServers,
 } from "@opencode-ai/app"
 import * as Sentry from "@sentry/solid"
 import type { AsyncStorage } from "@solid-primitives/storage"
 import { MemoryRouter } from "@solidjs/router"
-import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
 import { render } from "solid-js/web"
 import pkg from "../../package.json"
 import { initI18n, t } from "./i18n"
 import { initializationData, initializationReady } from "./initialization"
 import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
+import { availableStartupServer, readyWslConnections } from "./wsl/connections"
 import "./styles.css"
+import { Splash } from "@opencode-ai/ui/logo"
 import { useTheme } from "@opencode-ai/ui/theme/context"
 
 const root = document.getElementById("root")
@@ -80,27 +83,6 @@ const createPlatform = (): Platform => {
     return undefined
   })()
 
-  const isWslEnabled = async () => {
-    if (os !== "windows") return false
-    return window.api
-      .getWslConfig()
-      .then((config) => config.enabled)
-      .catch(() => false)
-  }
-
-  const wslHome = async () => {
-    if (!(await isWslEnabled())) return undefined
-    return window.api.wslPath("~", "windows").catch(() => undefined)
-  }
-
-  const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
-    if (!result || !(await isWslEnabled())) return result
-    if (Array.isArray(result)) {
-      return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
-    }
-    return window.api.wslPath(result, "linux").catch(() => result) as any
-  }
-
   const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
     switch (action) {
       case "view.resetZoom":
@@ -144,37 +126,34 @@ const createPlatform = (): Platform => {
     }
   })()
 
+  const wslServersApi = os === "windows" ? window.api.wslServers : undefined
+
   return {
     platform: "desktop",
     os,
     version: pkg.version,
 
     async openDirectoryPickerDialog(opts) {
-      const defaultPath = await wslHome()
-      const result = await window.api.openDirectoryPicker({
+      return window.api.openDirectoryPicker({
         multiple: opts?.multiple ?? false,
         title: opts?.title ?? t("desktop.dialog.chooseFolder"),
-        defaultPath,
       })
-      return await handleWslPicker(result)
     },
 
     async openFilePickerDialog(opts) {
-      const result = await window.api.openFilePicker({
+      return window.api.openFilePicker({
         multiple: opts?.multiple ?? false,
         title: opts?.title ?? t("desktop.dialog.chooseFile"),
         accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
         extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
       })
-      return handleWslPicker(result)
     },
 
     async saveFilePickerDialog(opts) {
-      const result = await window.api.saveFilePicker({
+      return window.api.saveFilePicker({
         title: opts?.title ?? t("desktop.dialog.saveFile"),
         defaultPath: opts?.defaultPath,
       })
-      return handleWslPicker(result)
     },
 
     openLink(url: string) {
@@ -183,14 +162,7 @@ const createPlatform = (): Platform => {
     async openPath(path: string, app?: string) {
       if (os === "windows") {
         const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
-        const resolvedPath = await (async () => {
-          if (await isWslEnabled()) {
-            const converted = await window.api.wslPath(path, "windows").catch(() => null)
-            if (converted) return converted
-          }
-          return path
-        })()
-        return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
+        return window.api.openPath(path, resolvedApp ?? undefined)
       }
       return window.api.openPath(path, app)
     },
@@ -247,12 +219,6 @@ const createPlatform = (): Platform => {
       return fetch(input, init)
     },
 
-    getWslEnabled: () => isWslEnabled(),
-
-    setWslEnabled: async (enabled) => {
-      await window.api.setWslConfig({ enabled })
-    },
-
     getDefaultServer: async () => {
       const url = await window.api.getDefaultServerUrl().catch(() => null)
       if (!url) return null
@@ -263,6 +229,8 @@ const createPlatform = (): Platform => {
       await window.api.setDefaultServerUrl(url)
     },
 
+    wslServers: wslServersApi,
+
     getDisplayBackend: async () => {
       return window.api.getDisplayBackend().catch(() => null)
     },
@@ -304,7 +272,6 @@ listenForDeepLinks()
 
 render(() => {
   const platform = createPlatform()
-  const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
   const loadLocale = async () => {
     const current = await platform.storage?.("opencode.global.dat").getItem("language")
     const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -322,29 +289,9 @@ render(() => {
   // Fetch sidecar credentials (available immediately, before health check)
   const [sidecar] = createResource(() => window.api.awaitInitialization())
 
-  const [defaultServer] = createResource(() =>
-    platform.getDefaultServer?.().then((url) => {
-      if (url) return ServerConnection.key({ type: "http", http: { url } })
-    }),
-  )
+  const [defaultServer] = createResource(() => platform.getDefaultServer?.())
   const [locale] = createResource(loadLocale)
 
-  const servers = () => {
-    const data = initializationData(sidecar)
-    if (!data) return []
-    const server: ServerConnection.Sidecar = {
-      displayName: "Local Server",
-      type: "sidecar",
-      variant: "base",
-      http: {
-        url: data.url,
-        username: data.username ?? undefined,
-        password: data.password ?? undefined,
-      },
-    }
-    return [server] as ServerConnection.Any[]
-  }
-
   function handleClick(e: MouseEvent) {
     const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
     if (link?.href) {
@@ -371,6 +318,52 @@ render(() => {
     return null
   }
 
+  function App() {
+    const wslServers = useWslServers()
+    const splash = (
+      <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+        <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+      </div>
+    )
+
+    const ready = createMemo(
+      () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
+    )
+    const servers = createMemo(() => {
+      const data = initializationData(sidecar)
+      const list: ServerConnection.Any[] = []
+      if (data) {
+        list.push({
+          displayName: "Local Server",
+          type: "sidecar",
+          variant: "base",
+          http: {
+            url: data.url,
+            username: data.username ?? undefined,
+            password: data.password ?? undefined,
+          },
+        })
+      }
+      list.push(...readyWslConnections(wslServers.data))
+      return list
+    })
+    const effectiveDefaultServer = createMemo(() =>
+      ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
+    )
+
+    return (
+      <Show when={ready()} fallback={splash}>
+        <Show when={effectiveDefaultServer()} keyed>
+          {(key) => (
+            <AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
+              <Inner />
+            </AppInterface>
+          )}
+        </Show>
+      </Show>
+    )
+  }
+
   onMount(() => {
     document.addEventListener("click", handleClick)
     onCleanup(() => {
@@ -381,27 +374,7 @@ render(() => {
   return (
     <PlatformProvider value={platform}>
       <AppBaseProviders locale={locale.latest}>
-        <Show
-          when={
-            !defaultServer.loading &&
-            initializationReady(sidecar) &&
-            !windowConfig.loading &&
-            !windowCount.loading &&
-            !locale.loading
-          }
-        >
-          {(_) => {
-            return (
-              <AppInterface
-                defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
-                servers={servers()}
-                router={MemoryRouter}
-              >
-                <Inner />
-              </AppInterface>
-            )
-          }}
-        </Show>
+        <Show when={true}>{(_) => <App />}</Show>
       </AppBaseProviders>
     </PlatformProvider>
   )

+ 43 - 0
packages/desktop/src/renderer/wsl/connections.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import type { WslServersState } from "@opencode-ai/app/wsl/types"
+import { availableStartupServer, readyWslConnections } from "./connections"
+
+const state = (kind: "starting" | "ready" | "failed" | "stopped"): WslServersState => ({
+  runtime: null,
+  installed: [],
+  online: [],
+  distroProbes: {},
+  opencodeChecks: {},
+  pendingRestart: false,
+  job: null,
+  servers: [
+    {
+      config: { id: "wsl:Debian", distro: "Debian" },
+      runtime: runtime(kind),
+    },
+  ],
+})
+
+function runtime(kind: "starting" | "ready" | "failed" | "stopped") {
+  if (kind === "ready") return { kind, url: "http://127.0.0.1:4096", username: "opencode", password: "secret" }
+  if (kind === "failed") return { kind, message: "boom" }
+  return { kind }
+}
+
+describe("WSL desktop connections", () => {
+  test("publishes a WSL server only after it reports ready", () => {
+    expect(readyWslConnections(state("starting"))).toEqual([])
+    expect(readyWslConnections(state("failed"))).toEqual([])
+    expect(readyWslConnections(state("stopped"))).toEqual([])
+    expect(readyWslConnections(state("ready"))).toEqual([
+      expect.objectContaining({ displayName: "Debian", label: "WSL" }),
+    ])
+  })
+
+  test("does not block desktop startup on a configured WSL default", () => {
+    const key = "wsl:Debian"
+    expect(availableStartupServer(key, undefined)).toBe("sidecar")
+    expect(availableStartupServer(key, state("starting"))).toBe("sidecar")
+    expect(availableStartupServer(key, state("ready"))).toBe(key)
+  })
+})

+ 28 - 0
packages/desktop/src/renderer/wsl/connections.ts

@@ -0,0 +1,28 @@
+import type { WslServersState } from "@opencode-ai/app/wsl/types"
+
+export function readyWslConnections(state?: WslServersState) {
+  return (state?.servers ?? []).flatMap((item) => {
+    if (item.runtime.kind !== "ready") return []
+    return [
+      {
+        displayName: item.config.distro,
+        label: "WSL",
+        type: "sidecar" as const,
+        variant: "wsl" as const,
+        distro: item.config.distro,
+        http: {
+          url: item.runtime.url,
+          username: item.runtime.username ?? undefined,
+          password: item.runtime.password ?? undefined,
+        },
+      },
+    ]
+  })
+}
+
+export function availableStartupServer(defaultServer: string | null | undefined, state?: WslServersState) {
+  const key = defaultServer ?? "sidecar"
+  if (!key.startsWith("wsl:")) return key
+  if (state?.servers.some((item) => item.config.id === key && item.runtime.kind === "ready")) return key
+  return "sidecar"
+}