- {current()?.installed.length
+ {visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
@@ -375,14 +401,24 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
Install
-
void run(() => wslServers()!.installDistro(installTarget()!.name))}
- >
- Install
-
+
+
+
+ {installDistroPercent()}%
+
+
+
+
+
+ void run(() => wslServers()!.installDistro(installTarget()!.name))}
+ >
+ {installingDistro() ? "Installing..." : "Install"}
+
+
serverName(props.conn))
+ const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
+ const version = createMemo(() => props.version ?? props.status?.version)
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.conn.http.url
- props.status?.version
+ version()
queueMicrotask(check)
})
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
{serverName(props.conn, true)}
-
- v{props.status?.version}
+
+ WSL
+
+
+ v{version()}
)
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
{name()}
+
+
+ WSL
+
+
+
- v{props.status?.version}
+ v{version()}
}
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index cad0b0673..1034f0676 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/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 { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { setServerSwitching } from "@/utils/server-switch"
@@ -91,7 +91,7 @@ const useDefaultServerKey = (
get: (() => string | Promise | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
- url: undefined as string | undefined,
+ key: undefined as ServerConnection.Key | undefined,
tick: 0,
})
@@ -100,7 +100,7 @@ const useDefaultServerKey = (
let dead = false
const result = get?.()
if (!result) {
- setState("url", undefined)
+ setState("key", undefined)
onCleanup(() => {
dead = true
})
@@ -110,7 +110,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
- setState("url", next ? normalizeServerUrl(next) : undefined)
+ setState("key", next ? ServerConnection.Key.make(next) : undefined)
})
onCleanup(() => {
dead = true
@@ -118,18 +118,14 @@ const useDefaultServerKey = (
return
}
- setState("url", normalizeServerUrl(result))
+ setState("key", ServerConnection.Key.make(result))
onCleanup(() => {
dead = true
})
})
return {
- key: () => {
- const u = state.url
- if (!u) return
- return ServerConnection.key({ type: "http", http: { url: u } })
- },
+ key: () => state.key,
refresh: () => setState("tick", (value) => value + 1),
}
}
@@ -306,7 +302,13 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
setTimeout(() => {
try {
batch(() => {
- navigate("/")
+ if (server.key !== key) {
+ if (typeof window !== "undefined" && window.history?.replaceState) {
+ window.history.replaceState(null, "", "/")
+ }
+ } else {
+ navigate("/")
+ }
server.setActive(key)
})
} finally {
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index edbbd752c..4997a16bd 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -74,6 +74,11 @@ const errorName = (err: unknown) => {
return typeof errorName === "string" ? errorName : undefined
}
+const logTerminal = (phase: string, input: Record) => {
+ if (!import.meta.env.DEV) return
+ console.log(`[terminal ui] ${JSON.stringify({ phase, ...input })}`)
+}
+
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -169,11 +174,11 @@ export const Terminal = (props: TerminalProps) => {
const server = useServer()
const directory = sdk.directory
const client = sdk.client
- const url = sdk.url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
- const sameOrigin = new URL(url, location.href).origin === location.origin
+ const currentUrl = () => server.current?.http.url ?? sdk.url
+ const sameOrigin = () => new URL(currentUrl(), location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -450,20 +455,32 @@ export const Terminal = (props: TerminalProps) => {
output.flush(resolve)
})
- if (restore && restoreSize) {
+ // Defer the serialised `restore` buffer until the WebSocket actually
+ // opens against the live PTY. Previously we wrote it synchronously
+ // before connect, which painted stale content on screen whenever the
+ // sidecar had restarted (e.g. a server swap): every saved pty id
+ // belongs to the old sidecar, so connect eventually fails and the
+ // clone handler wipes the buffer — but you'd see the old bash/pwsh
+ // scrollback flash first. Now `restore` is only applied once we know
+ // the pty is real (handleOpen), and if connect fails clone clears
+ // `buffer` in the store so the next mount has nothing to replay.
+ fit.fit()
+ scheduleSize(t.cols, t.rows)
+ startResize()
+
+ let restored = false
+ const applyRestore = async () => {
+ if (restored) return
+ restored = true
+ if (!restore) return
+ logTerminal("restore.apply", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ restoreLength: restore.length,
+ })
await write(restore)
- fit.fit()
- scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY)
- startResize()
- } else {
- fit.fit()
- scheduleSize(t.cols, t.rows)
- if (restore) {
- await write(restore)
- if (scrollY !== undefined) t.scrollToLine(scrollY)
- }
- startResize()
}
const once = { value: false }
@@ -509,17 +526,34 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
- const next = new URL(url + `/pty/${id}/connect`)
+ const baseUrl = currentUrl()
+ if (sdk.url !== baseUrl) {
+ console.error(
+ `[terminal panic] sdk.url mismatch id=${id} serverKey=${server.key ?? ""} directory=${directory} sdkUrl=${sdk.url} currentUrl=${baseUrl}`,
+ )
+ }
+
+ const next = new URL(baseUrl + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
- if (!sameOrigin && password) {
+ if (!sameOrigin() && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
+ logTerminal("socket.open", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ restoreLength: restore.length,
+ sdkUrl: sdk.url,
+ currentUrl: baseUrl,
+ wsUrl: next.toString(),
+ })
+
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket
@@ -527,6 +561,16 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
if (disposed) return
tries = 0
+ logTerminal("socket.connected", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ currentUrl: baseUrl,
+ })
+ // Paint the saved buffer now that we've confirmed the pty really
+ // exists on the current sidecar. Fire-and-forget: write()'s own
+ // flush keeps the data ordered with incoming WS messages.
+ void applyRestore()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
@@ -581,6 +625,14 @@ export const Terminal = (props: TerminalProps) => {
socket.removeEventListener("close", handleClose)
if (disposed) return
if (event.code === 1000) return
+ logTerminal("socket.closed", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ code: event.code,
+ reason: event.reason || null,
+ currentUrl: baseUrl,
+ })
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
@@ -591,6 +643,29 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("close", handleClose)
}
+ // If we're reconnecting to a saved pty AND we have a serialised buffer
+ // to replay, verify the pty still exists on the current sidecar BEFORE
+ // upgrading the WebSocket. Hono's upgradeWebSocket handler throws
+ // "Session not found" inside `onOpen` (packages/opencode/src/server/
+ // routes/instance/pty.ts:196-205), which means the client still gets a
+ // brief `open` event before the server closes the socket — enough to
+ // fire handleOpen and paint the stale buffer. Pre-checking turns this
+ // into a single pty.get() round-trip that routes directly into the
+ // clone path on NotFound, so restore never runs against a dead pty.
+ if (restore) {
+ logTerminal("restore.inspect", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ restoreLength: restore.length,
+ })
+ if (await gone()) {
+ if (!disposed) fail(new Error("Session not found"))
+ return
+ }
+ if (disposed) return
+ }
+
open()
}
@@ -606,6 +681,13 @@ export const Terminal = (props: TerminalProps) => {
})
onCleanup(() => {
+ logTerminal("cleanup", {
+ id,
+ serverKey: server.key ?? null,
+ directory,
+ cursor,
+ restoreLength: restore.length,
+ })
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index e53d60d5a..973ecc66b 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -235,7 +235,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})
return {
- url: currentServer.http.url,
+ get url() {
+ return server.current?.http.url ?? currentServer.http.url
+ },
client: sdk,
event: {
on: emitter.on.bind(emitter),
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 096ef23db..f6e94963e 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -171,6 +171,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
+ function nextActiveKey(exclude?: ServerConnection.Key) {
+ const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude)
+ const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer)
+ const next = preferred ?? available[0]
+ return next ? ServerConnection.key(next) : props.defaultServer
+ }
+
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
@@ -192,8 +199,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
batch(() => {
setStore("list", list)
if (state.active === key) {
- const next = list[0]
- setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
+ setState("active", nextActiveKey(key))
}
})
}
@@ -239,6 +245,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current: Accessor = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
+
+ createEffect(() => {
+ const list = allServers()
+ if (!list.length) return
+ if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
+ setState("active", nextActiveKey(state.active))
+ })
+
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts
index 6e07e0312..e33893ba7 100644
--- a/packages/app/src/context/terminal.test.ts
+++ b/packages/app/src/context/terminal.test.ts
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
-let getWorkspaceTerminalCacheKey: (dir: string) => string
+let getWorkspaceTerminalCacheKey: (dir: string, serverKey: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -22,8 +22,9 @@ beforeAll(async () => {
})
describe("getWorkspaceTerminalCacheKey", () => {
- test("uses workspace-only directory cache key", () => {
- expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
+ test("includes the server in the workspace cache key", () => {
+ expect(getWorkspaceTerminalCacheKey("/repo", "local:windows")).toBe("/repo:local:windows:__workspace__")
+ expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("/repo:wsl:Debian:__workspace__")
})
})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 482f55c71..c1f394166 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
+import { useServer } from "./server"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -21,6 +22,11 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
+const debugTerminal = (phase: string, input: Record) => {
+ if (!import.meta.env.DEV) return
+ console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`)
+}
+
function record(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -82,8 +88,8 @@ export function migrateTerminalState(value: unknown) {
}
}
-export function getWorkspaceTerminalCacheKey(dir: string) {
- return `${dir}:${WORKSPACE_KEY}`
+export function getWorkspaceTerminalCacheKey(dir: string, serverKey: string) {
+ return `${dir}:${serverKey}:${WORKSPACE_KEY}`
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@@ -111,10 +117,11 @@ const trimTerminal = (pty: LocalPTY) => {
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
- const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
- const entry = cache.get(key)
- entry?.value.clear()
+ for (const [key, entry] of cache.entries()) {
+ if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue
+ entry.value.clear()
+ }
}
void removePersisted(Persist.workspace(dir, "terminal"), platform)
@@ -130,14 +137,25 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
-function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) {
+function createWorkspaceTerminalSession(
+ sdk: ReturnType,
+ dir: string,
+ serverKey: string,
+ legacySessionID?: string,
+) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
+ const target = {
+ ...Persist.workspace(dir, `${serverKey}:terminal`, legacy),
+ migrate: migrateTerminalState,
+ }
+ // Scope persisted terminal state by server so switching servers behaves
+ // like switching projects: a fresh session for the new server+dir pair,
+ // while the other server's state stays intact until you swap back. PTY
+ // ids, scrollback, and WebSocket connections are all server-scoped, so
+ // cross-server persistence was showing stale output on swap.
const [store, setStore, _, ready] = persisted(
- {
- ...Persist.workspace(dir, "terminal", legacy),
- migrate: migrateTerminalState,
- },
+ target,
createStore<{
active?: string
all: LocalPTY[]
@@ -146,6 +164,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
}),
)
+ debugTerminal("session.create", {
+ dir,
+ serverKey,
+ storage: target.storage,
+ key: target.key,
+ legacySessionID: legacySessionID ?? null,
+ })
+
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
@@ -186,6 +212,16 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
onCleanup(unsub)
const update = (client: ReturnType["client"], pty: Partial & { id: string }) => {
+ debugTerminal("session.update", {
+ dir,
+ serverKey,
+ id: pty.id,
+ title: pty.title ?? null,
+ hasBuffer: typeof pty.buffer === "string",
+ bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0,
+ cursor: pty.cursor ?? null,
+ scrollY: pty.scrollY ?? null,
+ })
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
@@ -202,11 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
- console.error("Failed to update terminal", error)
+ console.error(
+ `Failed to update terminal ${JSON.stringify({
+ ptyID: pty.id,
+ title: pty.title,
+ error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error,
+ })}`,
+ )
})
}
const clone = async (client: ReturnType["client"], id: string) => {
+ debugTerminal("session.clone.start", { dir, serverKey, id })
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
@@ -220,6 +263,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
})
if (!next?.data) return
+ debugTerminal("session.clone.done", {
+ dir,
+ serverKey,
+ id,
+ nextID: next.data.id ?? null,
+ title: next.data.title ?? pty.title,
+ })
+
const active = store.active === pty.id
batch(() => {
@@ -252,11 +303,19 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
new() {
const nextNumber = pickNextTerminalNumber()
+ debugTerminal("session.new", { dir, serverKey, nextNumber })
+
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
+ debugTerminal("session.new.done", {
+ dir,
+ serverKey,
+ id,
+ title: pty.data?.title ?? defaultTitle(nextNumber),
+ })
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
@@ -289,6 +348,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
},
bind() {
const client = sdk.client
+ debugTerminal("session.bind", {
+ dir,
+ serverKey,
+ active: store.active ?? null,
+ all: store.all.map((item) => item.id),
+ })
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -357,6 +422,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
+ const server = useServer()
const params = useParams()
const cache = new Map()
@@ -364,7 +430,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
- // Snapshot disposers, then defer them to a microtask. When this runs
+ // Snapshot disposers, then defer them to a macrotask. When this runs
// from onCleanup during a parent remount (e.g. switching servers),
// calling dispose() synchronously starts a nested cleanNode cascade on
// a sibling root while the outer cascade is mid-traversal, corrupting
@@ -372,7 +438,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
// null (reading '1')` at chunk-*.js:992.
const pending = Array.from(cache.values(), (entry) => entry.dispose)
cache.clear()
- if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
+ if (pending.length) setTimeout(() => pending.forEach((d) => d()), 0)
}
onCleanup(disposeAll)
@@ -387,18 +453,33 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
- const loadWorkspace = (dir: string, legacySessionID?: string) => {
- // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
- const key = getWorkspaceTerminalCacheKey(dir)
+ const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => {
+ // Session ids, PTY ids, and terminal buffers are server-scoped. Project
+ // swaps remount this subtree, but server swaps do not, so the in-memory
+ // cache must be partitioned by server as well as directory.
+ const key = getWorkspaceTerminalCacheKey(dir, serverKey)
const existing = cache.get(key)
if (existing) {
+ debugTerminal("workspace.cache.hit", {
+ dir,
+ serverKey,
+ key,
+ legacySessionID: legacySessionID ?? null,
+ })
cache.delete(key)
cache.set(key, existing)
return existing.value
}
+ debugTerminal("workspace.cache.miss", {
+ dir,
+ serverKey,
+ key,
+ legacySessionID: legacySessionID ?? null,
+ })
+
const entry = createRoot((dispose) => ({
- value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
+ value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID),
dispose,
}))
@@ -407,16 +488,51 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
- const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
+ const unsupported = createMemo(() => {
+ const current = server.current
+ return current?.type === "sidecar" && current.variant === "wsl" && params.dir?.startsWith("/mnt/")
+ })
+
+ const unsupportedWorkspace = {
+ ready: () => true,
+ all: () => [] as LocalPTY[],
+ active: () => undefined as string | undefined,
+ clear() {},
+ new() {},
+ update(_pty: Partial & { id: string }) {},
+ trim(_id: string) {},
+ trimAll() {},
+ clone: async (_id: string) => {},
+ bind() {
+ return {
+ trim(_id: string) {},
+ update(_pty: Partial & { id: string }) {},
+ clone: async (_id: string) => {},
+ }
+ },
+ open(_id: string) {},
+ close: async (_id: string) => {},
+ move(_id: string, _to: number) {},
+ next() {},
+ previous() {},
+ } as unknown as ReturnType
+
+ const workspace = createMemo(() => {
+ if (unsupported()) return unsupportedWorkspace
+ const key = server.key
+ if (!key) return unsupportedWorkspace
+ return loadWorkspace(params.dir!, key, params.id)
+ })
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
- if (!prev?.dir) return
+ const prevKey = server.key
+ if (!prev?.dir || !prevKey) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
- loadWorkspace(prev.dir, prev.id).trimAll()
+ loadWorkspace(prev.dir, prevKey, prev.id).trimAll()
},
{ defer: true },
),
@@ -431,7 +547,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
- bind: () => workspace(),
+ bind: () => workspace().bind(),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts
index a13fd34ef..1b054c4ff 100644
--- a/packages/app/src/utils/server-health.ts
+++ b/packages/app/src/utils/server-health.ts
@@ -65,6 +65,21 @@ function retryable(error: unknown, signal?: AbortSignal) {
return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message)
}
+function serializeError(error: unknown): unknown {
+ if (error instanceof Error) {
+ return {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ }
+ }
+ return error
+}
+
+function stringifyLog(label: string, value: unknown) {
+ return `${label} ${JSON.stringify(value)}`
+}
+
export async function checkServerHealth(
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
@@ -74,7 +89,19 @@ export async function checkServerHealth(
const signal = opts?.signal ?? timeout?.signal
const retryCount = opts?.retryCount ?? defaultRetryCount
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
+ const logFailure = (phase: string, count: number, error: unknown) => {
+ console.error(
+ stringifyLog("[server health] request failed", {
+ phase,
+ attempt: count + 1,
+ url: server.url,
+ hasAuth: !!server.password,
+ error: serializeError(error),
+ }),
+ )
+ }
const next = (count: number, error: unknown) => {
+ logFailure("retry", count, error)
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
return wait(retryDelayMs * (count + 1), signal)
.then(() => attempt(count + 1))
@@ -87,7 +114,10 @@ export async function checkServerHealth(
signal,
})
.global.health()
- .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
+ .then((x) => {
+ if (x.error) return next(count, x.error)
+ return { healthy: x.data?.healthy === true, version: x.data?.version }
+ })
.catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.())
}
diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts
index eb0b260ea..0091f35e7 100644
--- a/packages/desktop-electron/src/main/apps.ts
+++ b/packages/desktop-electron/src/main/apps.ts
@@ -14,13 +14,40 @@ export function resolveAppPath(appName: string): string | null {
return resolveWindowsAppPath(appName)
}
+// Parses `\\wsl$\\...` and `\\wsl.localhost\\...` UNC paths that
+// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
+// backslashes get shell-collapsed when passed through `wsl.exe`, turning
+// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
+// which is a valid-looking path that wedges opencode on DrvFs stat calls.
+function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
+ // Normalise separators; both `\\` and `//` prefixes mean UNC.
+ const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
+ const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
+ if (!match) return null
+ const distro = match[2]
+ const subpath = match[3] ?? ""
+ return { distro, subpath }
+}
+
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise {
if (process.platform !== "win32") return path
+ // `\\wsl$\\...` / `\\wsl.localhost\\...` -> `/` in
+ // the target distro. Do the conversion in-process rather than shelling out
+ // to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
+ // joiner. If the requested distro differs from the UNC distro, we still
+ // translate literally — callers are responsible for only picking paths
+ // inside the active distro.
+ if (mode === "linux") {
+ const unc = parseWslUncPath(path)
+ if (unc) return `/${unc.subpath}`
+ }
+
const flag = mode === "windows" ? "-w" : "-u"
try {
const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path
- const output = await runWslInDistro(["wslpath", flag, resolved], distro)
+ const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
+ const output = await runWslInDistro(["wslpath", flag, input], distro)
if (output.code !== 0) {
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
}
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 4ee71ba2a..a89ce6fd2 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
+import * as nodeHttp from "node:http"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
@@ -41,7 +42,7 @@ import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
-import { store } from "./store"
+import { getStore } from "./store"
import { createWslServersController } from "./wsl-servers"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
@@ -75,11 +76,15 @@ logger.log("app starting", {
version: app.getVersion(),
packaged: app.isPackaged,
})
+// NOTE: the first getStore() call here is intentional — it is the earliest
+// point after `app.setName` / `app.setPath("userData", ...)` have run, so
+// electron-store correctly resolves its root to the channel-specific
+// userData dir (`...desktop.dev` in dev) rather than the package.json name.
logger.log("config paths", {
userData: app.getPath("userData"),
- settingsStore: store.path,
+ settingsStore: getStore().path,
wslServersKey: WSL_SERVERS_KEY,
- wslServers: store.get(WSL_SERVERS_KEY) ?? null,
+ wslServers: getStore().get(WSL_SERVERS_KEY) ?? null,
})
setupApp()
@@ -330,6 +335,7 @@ function wireMenu() {
}
registerIpcHandlers({
+ httpFetch: (input) => bridgedHttpFetch(input),
killSidecar: () => killSidecar(),
relaunch: () => relaunchApp(),
awaitInitialization: async (sendStep) => {
@@ -391,6 +397,82 @@ function relaunchApp() {
app.exit(0)
}
+// Uses node:http directly rather than global fetch (undici). On Windows,
+// undici pools keep-alive sockets across requests; the WSL2 port proxy
+// silently drops idle loopback sockets, so reusing one hangs until timeout.
+// `agent: false` + `Connection: close` forces a fresh TCP connection per
+// request, which is the only reliable way to hit a WSL-forwarded port.
+function bridgedHttpFetch(input: {
+ url: string
+ method: string
+ headers: Record
+ body?: string
+ timeoutMs?: number
+}): Promise<{
+ status: number
+ statusText: string
+ headers: Record
+ body: string
+}> {
+ return new Promise((resolve, reject) => {
+ let parsed: URL
+ try {
+ parsed = new URL(input.url)
+ } catch (error) {
+ reject(new Error(`httpFetch: invalid url ${input.url}: ${String(error)}`))
+ return
+ }
+ if (parsed.protocol !== "http:") {
+ reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`))
+ return
+ }
+
+ const req = nodeHttp.request({
+ host: parsed.hostname,
+ port: parsed.port ? Number(parsed.port) : 80,
+ path: `${parsed.pathname}${parsed.search}`,
+ method: input.method,
+ headers: { ...input.headers, connection: "close" },
+ agent: false,
+ })
+
+ const timeoutMs = input.timeoutMs ?? 15_000
+ req.setTimeout(timeoutMs, () => {
+ req.destroy(new Error(`httpFetch: timeout after ${timeoutMs}ms (${input.method} ${input.url})`))
+ })
+
+ req.once("error", (error) => {
+ const err = error as NodeJS.ErrnoException
+ const detail = [err.name, err.code, err.message].filter(Boolean).join(" | ")
+ reject(new Error(`httpFetch: ${detail || "unknown error"}`))
+ })
+
+ req.once("response", (res) => {
+ const chunks: Buffer[] = []
+ res.on("data", (chunk: Buffer) => chunks.push(chunk))
+ res.once("end", () => {
+ const headers: Record = {}
+ for (const [key, value] of Object.entries(res.headers)) {
+ if (value === undefined) continue
+ headers[key] = Array.isArray(value) ? value.join(", ") : String(value)
+ }
+ resolve({
+ status: res.statusCode ?? 0,
+ statusText: res.statusMessage ?? "",
+ headers,
+ body: Buffer.concat(chunks).toString("utf8"),
+ })
+ })
+ res.once("error", (error) => {
+ reject(new Error(`httpFetch response error: ${String(error)}`))
+ })
+ })
+
+ if (input.body !== undefined) req.write(input.body)
+ req.end()
+ })
+}
+
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index c6d2c4fac..54ef70492 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -21,6 +21,18 @@ const pickerFilters = (ext?: string[]) => {
}
type Deps = {
+ httpFetch: (input: {
+ url: string
+ method: string
+ headers: Record
+ body?: string
+ timeoutMs?: number
+ }) => Promise<{
+ status: number
+ statusText: string
+ headers: Record
+ body: string
+ }>
killSidecar: () => void
relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise
@@ -56,6 +68,13 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
+ const debugStore = (op: string, name: string, key: string, meta?: Record) => {
+ if (app.isPackaged) return
+ if (!name.startsWith("opencode.workspace.")) return
+ if (!key.includes("terminal")) return
+ console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`)
+ }
+
const offWslServers = deps.onWslServersEvent((payload) => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue
@@ -64,6 +83,13 @@ export function registerIpcHandlers(deps: Deps) {
})
app.once("will-quit", offWslServers)
+ ipcMain.handle(
+ "http-fetch",
+ (
+ _event: IpcMainInvokeEvent,
+ input: { url: string; method: string; headers: Record; body?: string; timeoutMs?: number },
+ ) => deps.httpFetch(input),
+ )
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
@@ -122,13 +148,24 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
const store = getStore(name)
const value = store.get(key)
+ debugStore("get", name, key, {
+ found: value !== undefined && value !== null,
+ length:
+ typeof value === "string"
+ ? value.length
+ : value === undefined || value === null
+ ? 0
+ : JSON.stringify(value).length,
+ })
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
})
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
+ debugStore("set", name, key, { length: value.length })
getStore(name).set(key, value)
})
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
+ debugStore("delete", name, key)
getStore(name).delete(key)
})
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts
index 70e3dc9c7..2c0b25b6a 100644
--- a/packages/desktop-electron/src/main/migrate.ts
+++ b/packages/desktop-electron/src/main/migrate.ts
@@ -67,7 +67,8 @@ function migrateFile(datPath: string, filename: string) {
}
export function migrate() {
- if (getStore().get(TAURI_MIGRATED_KEY)) {
+ const store = getStore()
+ if (store.get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 8bfd19ef2..8d65ee4aa 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -10,11 +10,18 @@ import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise }
export function getDefaultServerUrl(): string | null {
- const value = getStore().get(DEFAULT_SERVER_URL_KEY)
- return typeof value === "string" ? value : null
+ const store = getStore()
+ const value = store.get(DEFAULT_SERVER_URL_KEY)
+ if (typeof value !== "string") return null
+ if (value === "sidecar") {
+ store.set(DEFAULT_SERVER_URL_KEY, "local:windows")
+ return "local:windows"
+ }
+ return value
}
export function setDefaultServerUrl(url: string | null) {
+ const store = getStore()
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
@@ -73,7 +80,7 @@ export async function spawnLocalServer(hostname: string, port: number, password:
}
export type WslSidecar = {
- listener: { stop: () => void }
+ listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
@@ -98,16 +105,44 @@ export async function spawnWslSidecar(
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
+ const logLevel = app.isPackaged ? "WARN" : "INFO"
const script = [
"set -euo pipefail",
- "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
- "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
+ // wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
+ // to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
+ // instance middleware falls back to `process.cwd()` when a request
+ // arrives without a `directory=` query or `x-opencode-directory` header
+ // (see opencode server.ts InstanceMiddleware), and then calls
+ // `realpathSync(process.cwd())` synchronously on the main thread. A
+ // statx against a 9p path can wedge the whole event loop in kernel
+ // uninterruptible sleep, freezing the accept loop. Move cwd to the
+ // user's native Linux home so the fallback can't land on DrvFs.
+ 'cd "$HOME" || cd /',
+ // wsl.exe by default splices the Windows %PATH% into the distro's $PATH
+ // via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
+ // the sidecar spawns — PTY login shells, plugin helpers, etc. — then
+ // inherits it, which means `which pwsh.exe` resolves to the Windows
+ // PowerShell binary and bash-l profiles that end with
+ // eval "$(oh-my-posh init bash)" (or similar)
+ // silently run Windows pwsh for prompt rendering, whose banner
+ // ("Loading personal and system profiles took Xms.") then shows up in
+ // opencode's terminal pane. We want a clean, Linux-only environment in
+ // the sidecar, so filter every /mnt/* segment out of PATH and clear
+ // WSLENV so no further Windows vars leak in. Users who really need
+ // Windows binaries in the sidecar can invoke them by absolute path.
+ 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
+ "export PATH",
+ "export WSLENV=",
+ // WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
+ // watcher/discovery features off there because DrvFs/9p stalls can wedge
+ // the server process after it starts listening.
+ "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 WARN serve --hostname 0.0.0.0 --port ${port}`,
+ `exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
@@ -166,6 +201,9 @@ export async function spawnWslSidecar(
stop() {
child.kill()
},
+ onExit(cb) {
+ child.once("exit", cb)
+ },
},
url,
username,
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
index 61f0c0a49..b65f20a85 100644
--- a/packages/desktop-electron/src/main/store.ts
+++ b/packages/desktop-electron/src/main/store.ts
@@ -4,10 +4,12 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map()
-// We cannot instantiate the electron-store at module load time because
-// module import hoisting causes this to run before app.setPath("userData", ...)
-// in index.ts has executed, which would result in files being written to the default directory
-// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
+// IMPORTANT: do NOT construct Store at module import time. electron-store
+// resolves `app.getPath("userData")` in its constructor, but our index.ts
+// only calls `app.setName` / `app.setPath("userData", ...)` AFTER module
+// imports finish. Constructing eagerly wrote settings (e.g. the WSL server
+// config) to the default `%APPDATA%\@opencode-ai\desktop-electron` folder
+// instead of the proper `...desktop.dev` / channel dir.
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
diff --git a/packages/desktop-electron/src/main/wsl-pty.ts b/packages/desktop-electron/src/main/wsl-pty.ts
new file mode 100644
index 000000000..7f8bdfa6d
--- /dev/null
+++ b/packages/desktop-electron/src/main/wsl-pty.ts
@@ -0,0 +1,126 @@
+/** @ts-expect-error */
+import * as pty from "@lydell/node-pty"
+import type { RunWslOptions, WslCommandResult } from "./wsl"
+
+export function runInteractiveCommand(
+ command: string,
+ args: string[],
+ opts: RunWslOptions = {},
+ defaultTimeoutMs: number,
+) {
+ return new Promise((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
+ const parser = createInteractiveOutputParser((text) => opts.onLine?.({ stream: "stdout", text }))
+ let stdout = ""
+
+ const cleanup = () => {
+ clearTimeout(timeoutId)
+ abortCleanup?.()
+ parser.flush()
+ }
+
+ 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
+ parser.write(data)
+ })
+ child.onExit((event: { exitCode: number }) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
+ })
+ })
+}
+
+function createInteractiveOutputParser(onLine: (line: string) => void) {
+ let line = ""
+ let escape = ""
+ let lastProgress = ""
+
+ const emit = (value: string) => {
+ const text = value.trim()
+ if (!text) return
+ if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text)) {
+ if (text === lastProgress) return
+ lastProgress = text
+ }
+ onLine(text)
+ }
+
+ return {
+ write(chunk: string) {
+ for (const char of chunk) {
+ if (escape) {
+ escape += char
+ const isCsi = escape.startsWith("\u001b[")
+ const isOsc = escape.startsWith("\u001b]")
+ if ((isCsi && /[@-~]/.test(char)) || (isOsc && char === "\u0007") || escape.endsWith("\u001b\\")) {
+ escape = ""
+ } else if (!isCsi && !isOsc && escape.length > 1) {
+ escape = ""
+ }
+ continue
+ }
+ if (char === "\u001b") {
+ escape = "\u001b"
+ continue
+ }
+ if (char === "\b" || char === "\u007f") {
+ line = line.slice(0, -1)
+ continue
+ }
+ if (char === "\r" || char === "\n") {
+ emit(line)
+ line = ""
+ continue
+ }
+ line += char
+ if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(line)) emit(line)
+ }
+ },
+ flush() {
+ emit(line)
+ line = ""
+ },
+ }
+}
diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts
index c35e4e52b..9c9dc79e8 100644
--- a/packages/desktop-electron/src/main/wsl-servers.ts
+++ b/packages/desktop-electron/src/main/wsl-servers.ts
@@ -15,7 +15,7 @@ import type {
} from "../preload/types"
import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
import { spawnWslSidecar } from "./server"
-import { store } from "./store"
+import { getStore } from "./store"
import type { WslCommandLine } from "./wsl"
import {
installWslDistro,
@@ -33,7 +33,7 @@ import {
} from "./wsl"
type RunningSidecar = {
- listener: { stop: () => void }
+ listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
@@ -64,19 +64,29 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
for (const listener of listeners) listener({ type: "state", state })
}
+ const isProgressLine = (text: string) => {
+ return text.includes("[") && text.includes("]") && /(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text)
+ }
+
const setState = (next: Partial) => {
state = { ...state, ...next }
emit()
}
const appendTranscript = (line: Omit) => {
- setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] })
+ const next = { ...line, at: Date.now() }
+ const last = state.transcript.at(-1)
+ if (last && last.stream === line.stream && isProgressLine(last.text) && isProgressLine(line.text)) {
+ setState({ transcript: [...state.transcript.slice(0, -1), next] })
+ return
+ }
+ setState({ transcript: [...state.transcript, next] })
}
const clearTranscript = () => setState({ transcript: [] })
const persistServers = (servers: WslServerConfig[]) => {
- store.set(WSL_SERVERS_KEY, { servers })
+ getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
@@ -117,6 +127,30 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
updateServer(id, (item) => ({ ...item, runtime }))
}
+ const removeMissingServer = (id: string) => {
+ const remaining = readPersistedServers().filter((item) => item.id !== id)
+ persistServers(remaining)
+ setState({ servers: state.servers.filter((item) => item.config.id !== id) })
+ }
+
+ const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
+ setState({
+ opencodeChecks: {
+ ...state.opencodeChecks,
+ [distro]: check,
+ },
+ })
+ }
+
+ const refreshOpencodeCheck = async (
+ distro: string,
+ opts?: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void },
+ ) => {
+ const resolved = await resolveWslOpencode(distro, opts)
+ const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
+ setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
+ }
+
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
@@ -156,10 +190,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
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 })
+ mainLogger?.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)
+ mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
+ })
mainLogger?.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
+ if (isMissingDistroError(message)) {
+ removeMissingServer(id)
+ mainLogger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message })
+ 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
@@ -171,12 +221,12 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
const stopServerInternal = async (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
+ sidecars.delete(id)
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
- sidecars.delete(id)
}
const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => {
@@ -285,14 +335,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` })
- const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
- const version = resolved ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) : null
- setState({
- opencodeChecks: {
- ...state.opencodeChecks,
- [name]: opencodeCheck(name, resolved, version, appVersion),
- },
- })
+ await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
})
},
@@ -310,16 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
- const nextPath = await resolveWslOpencode(name, { signal: abort.signal, onLine })
- const nextVersion = nextPath
- ? await readWslCommandVersion(nextPath, name, { signal: abort.signal, onLine })
- : null
- setState({
- opencodeChecks: {
- ...state.opencodeChecks,
- [name]: opencodeCheck(name, nextPath, nextVersion, appVersion),
- },
- })
+ await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
})
},
@@ -408,6 +442,7 @@ function initialState(): WslServersState {
}
function readPersistedServers(): WslServerConfig[] {
+ const store = getStore()
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
@@ -420,7 +455,7 @@ function readPersistedServers(): WslServerConfig[] {
}
function migrateLegacyLocalServer(): WslServerConfig[] {
- const legacy = store.get(LEGACY_LOCAL_SERVER_KEY)
+ const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY)
if (!legacy || typeof legacy !== "object") return []
const record = legacy as Record
if (record.mode !== "wsl") return []
@@ -507,6 +542,14 @@ function summarize(value: string) {
.join("\n")
}
+function isMissingDistroError(message: string) {
+ return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message)
+}
+
+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,
diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts
index 07a22b825..1785df0f1 100644
--- a/packages/desktop-electron/src/main/wsl.ts
+++ b/packages/desktop-electron/src/main/wsl.ts
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
+import { runInteractiveCommand } from "./wsl-pty"
export type WslCommandLine = {
stream: "stdout" | "stderr"
@@ -13,7 +14,7 @@ export type WslCommandResult = {
stderr: string
}
-type RunWslOptions = {
+export type RunWslOptions = {
onLine?: (line: WslCommandLine) => void
signal?: AbortSignal
/**
@@ -28,6 +29,7 @@ type RunWslOptions = {
}
const DEFAULT_WSL_TIMEOUT_MS = 20_000
+const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
// `--user root` bypasses the distro's default-user requirement. A freshly
// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively
@@ -89,21 +91,37 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
return ""
}
+ const splitOutput = (pending: string) => {
+ const lines: string[] = []
+ let start = 0
+ for (let i = 0; i < pending.length; i++) {
+ const char = pending[i]
+ if (char !== "\r" && char !== "\n") continue
+ lines.push(pending.slice(start, i))
+ if (char === "\r" && pending[i + 1] === "\n") i += 1
+ start = i + 1
+ }
+ return {
+ lines,
+ pending: pending.slice(start),
+ }
+ }
+
const append = (stream: WslCommandLine["stream"], chunk: string) => {
if (!chunk) return
if (stream === "stdout") {
stdout += chunk
stdoutPending += chunk
- const lines = stdoutPending.split(/\r?\n/g)
- stdoutPending = lines.pop() ?? ""
- for (const line of lines) opts.onLine?.({ stream: "stdout", text: line })
+ const next = splitOutput(stdoutPending)
+ stdoutPending = next.pending
+ for (const line of next.lines) opts.onLine?.({ stream: "stdout", text: line })
return
}
stderr += chunk
stderrPending += chunk
- const lines = stderrPending.split(/\r?\n/g)
- stderrPending = lines.pop() ?? ""
- for (const line of lines) opts.onLine?.({ stream: "stderr", text: line })
+ const next = splitOutput(stderrPending)
+ stderrPending = next.pending
+ for (const line of next.lines) opts.onLine?.({ stream: "stderr", text: line })
}
child.stdout.on("data", (chunk: Buffer) => {
@@ -288,7 +306,7 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) {
}
export async function installWslRuntime(opts?: RunWslOptions) {
- return runWsl(["--install", "--no-distribution"], opts)
+ return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
@@ -297,18 +315,23 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
"$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, opts)
+ return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
- return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts)
+ return runInteractiveCommand(
+ "wsl",
+ ["--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 runWslBash(
`curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`,
distro,
- opts,
+ withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
)
}
@@ -420,10 +443,17 @@ export async function readWslCommandVersion(command: string, distro: string, opt
}
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
- return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts)
+ return runWslBash(
+ `${shellEscape(command)} upgrade ${shellEscape(target)}`,
+ distro,
+ withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
+ )
}
export function openWslTerminal(distro?: string | null) {
+ if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
+ return Promise.reject(new Error("Invalid distro name"))
+ }
return new Promise((resolve, reject) => {
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
detached: true,
@@ -489,3 +519,10 @@ function summarize(value: string) {
function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
+
+function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
+ return {
+ ...opts,
+ timeoutMs: opts?.timeoutMs ?? timeoutMs,
+ }
+}
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index faf0d692c..ea8675a87 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
const api: ElectronAPI = {
+ httpFetch: (input) => ipcRenderer.invoke("http-fetch", input),
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: (onStep) => {
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index 18183868a..98e61a66c 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -125,6 +125,18 @@ export type TitlebarTheme = {
}
export type ElectronAPI = {
+ httpFetch: (input: {
+ url: string
+ method: string
+ headers: Record
+ body?: string
+ timeoutMs?: number
+ }) => Promise<{
+ status: number
+ statusText: string
+ headers: Record
+ body: string
+ }>
killSidecar: () => Promise
installCli: () => Promise
awaitInitialization: (onStep: (step: InitStep) => void) => Promise
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 7aae90348..f7342e9be 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -70,7 +70,7 @@ import {
} from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
-import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
@@ -140,6 +140,48 @@ const createPlatform = (): Platform => {
return window.api.wslPath("~", "windows", distro).catch(() => undefined)
}
+ // SSE endpoints must keep a live connection; IPC-bridged fetch buffers the
+ // whole response body in main before returning, which breaks streams.
+ const isStreamingPath = (pathname: string) =>
+ pathname.endsWith("/event") || pathname === "/global/event" || pathname.endsWith("/pty/read")
+
+ // Chromium's network stack on Windows frequently stalls on WSL2-forwarded
+ // loopback ports (happy-eyeballs to [::1] hits the WSL port proxy which
+ // only binds v4). Node/undici in main has no such issue, so we route WSL
+ // loopback requests through the main process. `localhost`/`[::1]` are also
+ // loopback spellings we need to catch.
+ const isLoopback = (hostname: string) =>
+ hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]"
+
+ const shouldBridge = (url: URL) => {
+ if (!activeWslDistro()) return false
+ if (url.protocol !== "http:") return false
+ if (!isLoopback(url.hostname)) return false
+ if (isStreamingPath(url.pathname)) return false
+ return true
+ }
+
+ const bridgedFetch = async (request: Request, timeoutMs?: number) => {
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.clone().text()
+ const res = await window.api.httpFetch({
+ url: request.url,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ body,
+ timeoutMs,
+ })
+ // Null-body statuses (101/204/205/304) must be constructed with a null
+ // body or the Response constructor throws `Response with null body
+ // status cannot have body`. The IPC layer always hands us `res.body` as
+ // a string, so coerce to null for these statuses.
+ const nullBody = res.status === 101 || res.status === 204 || res.status === 205 || res.status === 304
+ return new Response(nullBody ? null : res.body, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: res.headers,
+ })
+ }
+
const handleWslPicker = async (result: T | null): Promise => {
const distro = activeWslDistro()
if (!result || !distro) return result
@@ -272,8 +314,47 @@ const createPlatform = (): Platform => {
},
fetch: (input, init) => {
- if (input instanceof Request) return fetch(input)
- return fetch(input, init)
+ const request = input instanceof Request ? (init ? new Request(input, init) : input) : new Request(input, init)
+ const url = (() => {
+ try {
+ return new URL(request.url, location.href)
+ } catch {
+ return null
+ }
+ })()
+ if (!url || !shouldBridge(url)) {
+ if (input instanceof Request && !init) return fetch(input)
+ return fetch(request)
+ }
+ // Propagate the request's own abort signal to the bridge via a finite
+ // timeout. If nothing set one we default to 15s so connects can't hang
+ // forever waiting on a dead WSL port proxy.
+ const signal = request.signal
+ const timeoutMs = 15_000
+ return new Promise((resolve, reject) => {
+ let settled = false
+ const onAbort = () => {
+ if (settled) return
+ settled = true
+ reject(new DOMException("Aborted", "AbortError"))
+ }
+ if (signal?.aborted) return onAbort()
+ signal?.addEventListener("abort", onAbort, { once: true })
+ bridgedFetch(request, timeoutMs).then(
+ (res) => {
+ if (settled) return
+ settled = true
+ signal?.removeEventListener("abort", onAbort)
+ resolve(res)
+ },
+ (err) => {
+ if (settled) return
+ settled = true
+ signal?.removeEventListener("abort", onAbort)
+ reject(err)
+ },
+ )
+ })
},
getDefaultServer: async () => {
@@ -368,7 +449,7 @@ render(() => {
onCleanup(off)
}
- const servers = () => {
+ const servers = createMemo(() => {
const data = startup.latest?.sidecar
const list: ServerConnection.Any[] = []
if (data) {
@@ -398,7 +479,7 @@ render(() => {
url: `http://wsl-${item.config.distro}.invalid`,
}
list.push({
- displayName: `WSL: ${item.config.distro}`,
+ displayName: item.config.distro,
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
@@ -407,7 +488,7 @@ render(() => {
}
}
return list
- }
+ })
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs
index 070d0c71f..d6ed08644 100644
--- a/packages/desktop/src-tauri/src/server.rs
+++ b/packages/desktop/src-tauri/src/server.rs
@@ -24,7 +24,20 @@ pub fn get_default_server_url(app: AppHandle) -> Result, String>
let value = store.get(DEFAULT_SERVER_URL_KEY);
match value {
- Some(v) => Ok(v.as_str().map(String::from)),
+ Some(v) => match v.as_str() {
+ Some("sidecar") => {
+ store.set(
+ DEFAULT_SERVER_URL_KEY,
+ serde_json::Value::String("local:windows".to_string()),
+ );
+ store
+ .save()
+ .map_err(|e| format!("Failed to save settings: {}", e))?;
+ Ok(Some("local:windows".to_string()))
+ }
+ Some(value) => Ok(Some(value.to_string())),
+ None => Ok(None),
+ },
None => Ok(None),
}
}