From f327584e0ad13cbd39bc47753fec68b4b58cdc46 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:00:46 +1000 Subject: [PATCH 01/88] fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) --- packages/app/e2e/backend.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 9febc4b3ff4d..a03d1d437504 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") { throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) } +function done(proc: ReturnType) { + return proc.exitCode !== null || proc.signalCode !== null +} + async function waitExit(proc: ReturnType, timeout = 10_000) { - if (proc.exitCode !== null) return + if (done(proc)) return await Promise.race([ new Promise((resolve) => proc.once("exit", () => resolve())), new Promise((resolve) => setTimeout(resolve, timeout)), @@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }): return { url, async stop() { - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGTERM") await waitExit(proc) } - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGKILL") await waitExit(proc) } From da2e91cf60ab4ae49774531e81876ce67eb601b8 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:01:10 +1000 Subject: [PATCH 02/88] ci use node 24 in test workflow fixing random ECONNRESET (#21782) --- .github/workflows/test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70a8477fb51f..510f682549ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,9 @@ permissions: contents: read checks: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit: name: unit (${{ matrix.settings.name }}) @@ -38,6 +41,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun @@ -102,6 +110,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun From c92aaf8b8096f5cb58bdd22989d2cd44c9cf6ce7 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 15:03:00 +0800 Subject: [PATCH 03/88] fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) --- packages/ui/src/components/accordion.css | 4 ++-- packages/ui/src/components/session-review.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css index b4d6323d0dee..cebb887f1f62 100644 --- a/packages/ui/src/components/accordion.css +++ b/packages/ui/src/components/accordion.css @@ -51,10 +51,10 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - &:hover { + &:hover:not([data-disabled]) { background-color: var(--surface-base-hover); } - &:active { + &:active:not([data-disabled]) { background-color: var(--surface-base-active); } &:focus-visible { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 4a7205a5dc56..3223f5d08dd6 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -388,6 +388,9 @@ export const SessionReview = (props: SessionReviewProps) => { let wrapper: HTMLDivElement | undefined const file = diff.file + // binary files have empty diffs that we can't render + const diffCanRender = () => diff.additions !== 0 && diff.deletions !== 0 + const expanded = createMemo(() => open().includes(file)) const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] @@ -496,14 +499,14 @@ export const SessionReview = (props: SessionReviewProps) => { return ( - +
@@ -512,7 +515,7 @@ export const SessionReview = (props: SessionReviewProps) => { {`\u202A${getDirectory(file)}\u202C`} {getFilename(file)} - +
From ba145fd61a662dd0a14b1a2b93d164c2e146a74e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:29 +1000 Subject: [PATCH 04/88] docs: add WSL onboarding implementation backlog --- plan.md | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 000000000000..45d3eabc39d7 --- /dev/null +++ b/plan.md @@ -0,0 +1,320 @@ +# WSL Local Server Implementation Backlog + +This backlog assumes Electron only. +It is ordered chronologically. +Each task is intended to be small enough for a new engineer to pick up directly. + +## 01 Foundation + +- [ ] Add a new persisted `localServer` settings key in `packages/desktop-electron/src/main/constants.ts`. +- [ ] Define a `LocalServerMode` type with `"windows" | "wsl"` in the Electron preload types. +- [ ] Define a persisted `LocalServerConfig` shape in the Electron preload types. +- [ ] Include `mode`, `distro`, onboarding metadata, root acknowledgements, and mismatch acknowledgements in `LocalServerConfig`. +- [ ] Remove the old `WslConfig` type from `packages/desktop-electron/src/preload/types.ts`. +- [ ] Remove the old `WSL_ENABLED_KEY` constant and stop adding new code that depends on it. +- [ ] Add a single source of truth helper in Electron main for reading `LocalServerConfig` from `electron-store`. +- [ ] Add a single source of truth helper in Electron main for writing `LocalServerConfig` to `electron-store`. +- [ ] Ignore any legacy `wslEnabled` value during reads of the new Local Server config. +- [ ] Add explicit comments in the new config helpers that this feature is Electron-only and replaces the legacy WSL boolean. + +## 02 Main-Process Local Server Controller + +- [ ] Create a dedicated Electron main module for Local Server orchestration, for example `packages/desktop-electron/src/main/local-server.ts`. +- [ ] Move local runtime orchestration out of ad hoc startup code and into the Local Server controller. +- [ ] Define an in-memory `LocalServerState` shape for runtime status, current job, current transcript, and startup failure. +- [ ] Keep `LocalServerState` in memory only; do not persist runtime status or raw logs. +- [ ] Add a typed event emitter inside the Local Server controller. +- [ ] Support one active Local Server job at a time in the controller. +- [ ] Add a helper to reject or cancel a previous Local Server job before starting a new one. +- [ ] Allocate the loopback port once per app launch and keep it stable for all Local Server restarts in that launch. +- [ ] Allocate the local auth password once per app launch and keep it stable for all Local Server restarts in that launch. +- [ ] Expose a controller method to return a full current Local Server snapshot. +- [ ] Expose a controller method to subscribe to Local Server events. +- [ ] Expose a controller method to update persisted Local Server config. +- [ ] Expose a controller method to run a specific wizard step. +- [ ] Expose a controller method to apply Local Server runtime changes in the background. +- [ ] Expose a controller method to cancel the current Local Server job. +- [ ] Expose a controller method to open a terminal for the selected distro. + +## 03 Windows Local Runtime Path + +- [ ] Move the existing Windows local sidecar startup path from `packages/desktop-electron/src/main/server.ts` into the Local Server controller. +- [ ] Keep the existing Windows local runtime behavior unchanged when `mode === "windows"`. +- [ ] Keep the existing eager local startup behavior unchanged for Windows local mode. +- [ ] Keep the existing loopback no-proxy behavior unchanged for Windows local mode. +- [ ] Update the controller snapshot so Windows local mode reports a distinct runtime key instead of the old implicit bare `sidecar` assumption. + +## 04 WSL Process Helpers + +- [ ] Add a helper to spawn `wsl.exe` with a selected distro and stream stdout/stderr lines. +- [ ] Standardize all WSL command execution on `wsl.exe -d -- bash -lc ...`. +- [ ] Add a helper to kill the currently spawned WSL child process when a job is canceled. +- [ ] Do not call `wsl --terminate ` as part of normal cancel behavior. +- [ ] Add a helper to run a short command and collect stdout/stderr for probe steps. +- [ ] Add a helper to resolve the selected distro home directory via `~`. +- [ ] Add a helper to run `command -v opencode` inside the selected distro. +- [ ] Add a helper to resolve `opencode --version` inside the selected distro. +- [ ] Add a helper to detect the selected distro default username via shell commands. +- [ ] Add a helper to detect whether the selected distro default user is `root`. +- [ ] Add a helper to detect whether `bash` exists in the selected distro. +- [ ] Add a helper to detect whether `curl` exists in the selected distro. +- [ ] Add a helper to run `opencode upgrade ` inside the selected distro. +- [ ] Add a helper to launch the Local Server with a resolved absolute executable path instead of bare `opencode`. + +## 05 WSL Runtime and Distro Probes + +- [ ] Add a helper to probe whether `wsl.exe` is available and usable. +- [ ] Add a helper to list installed distros with `wsl --list --verbose`. +- [ ] Add a helper to list online distros with `wsl --list --online`. +- [ ] Do not add system-distro filtering logic; keep all returned distros visible. +- [ ] Add probe parsing for `WSL 1` vs `WSL 2` from installed distro data. +- [ ] Treat `WSL 2` as required for first-class onboarding. +- [ ] Add explicit probe output for `missing bash`. +- [ ] Add explicit probe output for `missing curl`. +- [ ] Add explicit probe output for `cannot execute commands in distro`. +- [ ] Add explicit probe output for `default user is root`. +- [ ] Add explicit probe output for `distro not found`. + +## 06 WSL Install Helpers + +- [ ] Add a helper to run elevated `wsl --install --no-distribution` from Electron main. +- [ ] Implement that elevation path with a shell-based helper invocation rather than a bundled helper binary. +- [ ] Add a helper to install a distro with `wsl --install -d --web-download --no-launch`. +- [ ] Do not auto-install a default distro as part of the WSL runtime install step. +- [ ] Add a helper to detect whether a WSL install requires reboot. +- [ ] Add a helper to expose a `Restart now` action. +- [ ] Add a helper to mark onboarding as pending reboot when the user chooses `Later`. + +## 07 OpenCode Runtime Detection and Repair + +- [ ] Resolve `command -v opencode` on each startup when `mode === "wsl"`. +- [ ] Re-resolve `command -v opencode` on each explicit WSL apply/retry action. +- [ ] Compare the detected WSL `opencode` version to the desktop app version. +- [ ] Record mismatch acknowledgement once per resolved path plus version pair. +- [ ] Keep version mismatch non-blocking. +- [ ] Treat `Use anyway` as sufficient to complete the OpenCode step with warning. +- [ ] Implement `Install matching version` by running `opencode upgrade ` first. +- [ ] If `opencode upgrade` hangs, prompts, or fails, mark the repair attempt failed and stop automation. +- [ ] Surface the failed upgrade transcript in the Local Server UI. +- [ ] Do not add an automatic fallback installer path after `opencode upgrade` fails. +- [ ] Surface manual recovery commands instead. + +## 08 Startup Handshake and App Boot + +- [ ] Replace the current success-only startup payload with a ready-or-failed startup union. +- [ ] Include local runtime metadata in the startup payload. +- [ ] Include the local runtime key in the startup payload. +- [ ] Include runtime variant details in the startup payload. +- [ ] Include selected distro in the startup payload when `mode === "wsl"`. +- [ ] Include loopback URL and credentials in the startup payload even when the local runtime later fails health. +- [ ] Include startup failure step and message in the startup payload when the local runtime fails. +- [ ] Update `packages/desktop-electron/src/main/index.ts` to initialize Local Server through the controller. +- [ ] Keep the loading overlay generic and do not add WSL-specific overlay phases in v1. +- [ ] Add a startup health-verdict timeout for WSL local startup so the app can open after failure. +- [ ] Scope the startup timeout to the local health verdict only, not to sqlite migration. +- [ ] Open the main window after startup reaches a ready-or-failed local verdict. + +## 09 IPC and Preload API + +- [ ] Add a namespaced `localServer` API to the Electron preload surface. +- [ ] Implement `localServer.getState()`. +- [ ] Implement `localServer.subscribe()` with unsubscribe support. +- [ ] Implement `localServer.setConfig()`. +- [ ] Implement `localServer.runStep()`. +- [ ] Implement `localServer.apply()` for background runtime switching. +- [ ] Implement `localServer.cancelJob()`. +- [ ] Implement `localServer.openTerminal()`. +- [ ] Implement `localServer.restartNow()` for reboot-required flows. +- [ ] Implement `localServer.copyTranscript()` or equivalent transcript fetch action. +- [ ] Emit typed step/state events from main to renderer. +- [ ] Emit raw stdout/stderr line events from main to renderer. +- [ ] Remove `getWslConfig` from the preload API. +- [ ] Remove `setWslConfig` from the preload API. +- [ ] Remove `get-wsl-config` and `set-wsl-config` IPC handlers. + +## 10 Renderer Startup and Platform Wiring + +- [ ] Update the desktop renderer startup resource to consume the new startup union shape. +- [ ] Build the Local Server `ServerConnection.Sidecar` from structured startup metadata instead of hardcoding `variant: "base"`. +- [ ] Keep the visible Local Server display name as `Local Server` in both Windows and WSL modes. +- [ ] Add a WSL badge or subtitle in the row UI instead of renaming the server. +- [ ] Change the implicit local fallback key to follow the configured Local Server runtime. +- [ ] Remove all uses of `window.__OPENCODE__.wsl` from the renderer. +- [ ] Derive WSL picker/path behavior from structured Local Server state instead of a global boolean. +- [ ] Update `createPlatform()` so WSL path conversion only activates when Local Server mode is WSL. +- [ ] Default native pickers to the selected distro home path when Local Server mode is WSL. +- [ ] Keep native pickers on normal Windows behavior when Local Server mode is Windows. + +## 11 Distro-Aware Path Conversion + +- [ ] Update `packages/desktop-electron/src/main/apps.ts` so `wslPath()` accepts a distro parameter. +- [ ] Stop using the ambient default WSL distro for path conversion. +- [ ] Use the selected Local Server distro for all `~` resolution. +- [ ] Use the selected Local Server distro for all Windows-to-Linux path conversion. +- [ ] Use the selected Local Server distro for all Linux-to-Windows path conversion. +- [ ] Update open-path behavior to use distro-aware conversion when Local Server mode is WSL. + +## 12 App Server Model Changes + +- [ ] Introduce a distinct explicit key for Windows Local Server. +- [ ] Keep WSL Local Server keyed by distro identity. +- [ ] Update `packages/app/src/context/server.tsx` so Windows local and WSL local do not collapse into the same project-history bucket. +- [ ] Update `projectsKey()` to keep Windows local and WSL local histories separate. +- [ ] Update `isLocal()` so WSL Local Server still counts as local. +- [ ] Ensure Local Server key changes force the expected remount behavior through `ServerKey` in `packages/app/src/app.tsx`. +- [ ] If Local Server is currently active, make successful runtime switches follow the new local key automatically. + +## 13 Manage Servers Dialog Shell + +- [ ] Add a pinned `Local Server` row to `packages/app/src/components/dialog-select-server.tsx` list mode. +- [ ] Extract a dedicated Local Server page component instead of growing `dialog-select-server.tsx` further. +- [ ] Add a dialog mode or route that opens the dedicated Local Server page from the server list. +- [ ] Keep existing HTTP add/edit/delete/default flows untouched while adding the Local Server entry. +- [ ] Add an initial-view prop so the Manage Servers dialog can open directly to Local Server. + +## 14 Local Server Wizard + +- [ ] Implement a dedicated Local Server wizard component. +- [ ] Implement step order exactly as `WSL -> Distro -> OpenCode -> Switch`. +- [ ] Allow the user to go back and edit earlier steps. +- [ ] Persist wizard progress inside `LocalServerConfig`. +- [ ] Auto-resume the wizard after app relaunch when onboarding is incomplete. +- [ ] Auto-resume the wizard after reboot when onboarding was waiting for restart. +- [ ] Mark the wizard complete only after Local Server hot restart succeeds and health passes. + +## 15 WSL Step UI + +- [ ] Show current WSL runtime probe result in the WSL step. +- [ ] Add an `Install WSL` action that starts elevated `wsl --install --no-distribution`. +- [ ] Show reboot-required state in the WSL step when the install path requires restart. +- [ ] Add `Restart now` and `Later` actions. +- [ ] Keep the WSL step editable after the user returns from reboot. + +## 16 Distro Step UI + +- [ ] Show installed distros with explicit probe status in the Distro step. +- [ ] Show quick install actions for `Debian` and `Ubuntu 24`. +- [ ] Show an `Other distro...` action that reads from the online distro list. +- [ ] After distro install, auto-select the newly installed distro and continue probing automatically. +- [ ] Surface `WSL 1` as unsupported with manual conversion instructions. +- [ ] Surface `missing bash` as an explicit unsupported reason. +- [ ] Surface `missing curl` as an explicit unsupported reason. +- [ ] Surface `cannot execute commands` as an explicit unsupported reason. +- [ ] Surface `default user is root` as a warning in the Distro step. +- [ ] Require explicit root acknowledgement once per distro. +- [ ] Keep all distros visible even when unsupported. +- [ ] If the selected distro disappears, show an explicit missing-distro error instead of auto-switching away. + +## 17 OpenCode Step UI + +- [ ] Show the resolved absolute `opencode` path for the selected distro. +- [ ] Show the detected `opencode` version for the selected distro. +- [ ] Show version mismatch as a non-blocking warning. +- [ ] Add `Use anyway` in the mismatch state. +- [ ] Add `Install matching version` in the mismatch state. +- [ ] Keep mismatch acknowledgement scoped to path plus version. +- [ ] Re-warn only when the resolved path or resolved version changes later. +- [ ] If `command -v opencode` resolves nothing, show explicit manual recovery guidance. +- [ ] If `opencode upgrade` fails, keep the step incomplete and show the transcript. + +## 18 Switch Step UI + +- [ ] Add a `Switch Local Server` action that applies the new Local Server runtime in the background. +- [ ] Keep remote sessions usable while the Switch step runs. +- [ ] Reuse the current app-launch port and password during background Local Server restarts. +- [ ] Keep the Local Server active selection on Local Server when the switch succeeds and Local Server was already active. +- [ ] Show success only after `/global/health` succeeds for the new runtime. +- [ ] If background apply fails, show a `Restart OpenCode` fallback prompt. + +## 19 Local Server Status Dashboard + +- [ ] Replace the wizard with a steady-state dashboard after onboarding completes. +- [ ] Show current mode on the dashboard. +- [ ] Show selected distro on the dashboard when in WSL mode. +- [ ] Show current Local Server health on the dashboard. +- [ ] Show current failure state on the dashboard when the last startup or apply failed. +- [ ] Show version mismatch warning on the dashboard when the user chose `Use anyway`. +- [ ] Show root warning on the dashboard when the selected distro is root-backed and acknowledged. +- [ ] Do not show stale last-known-good probe values outside the current failure context. +- [ ] Add dashboard actions for `Retry`, `Open terminal`, and transcript copy. + +## 20 Live Diagnostics + +- [ ] Add a live diagnostics panel to the Local Server UI. +- [ ] Stream merged stdout/stderr lines into the panel while jobs are running. +- [ ] Keep the panel usable for startup failures from the current app launch. +- [ ] Retain the full Local Server transcript only for the current app launch. +- [ ] Clear the retained transcript on full app relaunch. +- [ ] Show exact commands in the diagnostics details area. +- [ ] Make `Copy commands` copy the same transcript content as the transcript-copy action. +- [ ] Keep diagnostics collapsible by default. + +## 21 Connection Error and Deep Linking + +- [ ] Add a direct `Open Local Server` CTA to the existing `ConnectionError` screen when the failing server is Local Server. +- [ ] Make that CTA open the Manage Servers dialog directly to the Local Server page. +- [ ] When Local Server startup failed earlier in the same launch, jump the Local Server UI directly to the failing step or dashboard state. +- [ ] Keep existing retry behavior for non-local remote servers unchanged. + +## 22 Runtime Apply and Background Behavior + +- [ ] Apply Local Server runtime changes in the background when the active server is remote. +- [ ] Do not navigate away from a remote session during a Local Server background apply. +- [ ] Keep Local Server config separate from active/default server selection logic. +- [ ] Do not auto-select Local Server just because its runtime config changed. +- [ ] Do not auto-change the user's default remote server selection when Local Server mode changes. + +## 23 Main Window Startup Failure Handling + +- [ ] If WSL Local Server fails during startup, keep the app launch going after the health-verdict timeout. +- [ ] Represent that failure in the startup payload instead of throwing away initialization. +- [ ] Keep the Local Server row present in the server list even when startup failed. +- [ ] Mark the Local Server row unhealthy when startup failed. +- [ ] Keep the startup loading overlay generic even in this failed case. + +## 24 API Cleanup and Legacy Removal + +- [ ] Remove the old hidden WSL settings UI branch from `packages/app/src/components/settings-general.tsx`. +- [ ] Remove legacy renderer calls that assume a boolean WSL mode. +- [ ] Remove legacy IPC registrations for the boolean WSL config. +- [ ] Remove legacy preload typing for the boolean WSL config. +- [ ] Remove legacy main-process store helpers that only read/write `wslEnabled`. + +## 25 Manual Recovery and Power Actions + +- [ ] Implement `Open terminal` as `open selected distro shell only` and do not auto-run recovery commands. +- [ ] Make `Open terminal` target the selected distro explicitly. +- [ ] Add a transcript copy action that is available even after failed jobs. +- [ ] Keep manual recovery command text aligned with the actual commands the controller runs. +- [ ] Include manual commands for WSL 1 conversion in the Distro step. +- [ ] Include manual commands for missing `curl` in the Distro or OpenCode step as appropriate. +- [ ] Include manual commands for PATH install version repair in the OpenCode step failure state. + +## 26 Verification and QA + +- [ ] Verify Windows Local Server behavior is unchanged when `mode === "windows"`. +- [ ] Verify the app still boots normally with no Local Server config present. +- [ ] Verify the app opens after a WSL startup failure instead of hanging forever. +- [ ] Verify `Install WSL` can reach a reboot-required state and resume after relaunch. +- [ ] Verify `Restart now` and `Later` both preserve onboarding state correctly. +- [ ] Verify Debian quick install auto-selects the new distro and continues onboarding. +- [ ] Verify Ubuntu 24 quick install auto-selects the new distro and continues onboarding. +- [ ] Verify `Other distro...` uses the live online catalog. +- [ ] Verify a WSL 1 distro surfaces manual conversion instructions. +- [ ] Verify a distro missing `bash` surfaces an explicit unsupported reason. +- [ ] Verify a distro missing `curl` surfaces an explicit unsupported reason. +- [ ] Verify a root-backed distro requires acknowledgement once per distro. +- [ ] Verify PATH-installed `opencode` is re-resolved on each startup. +- [ ] Verify mismatch acknowledgement only reappears when path or version changes. +- [ ] Verify `Use anyway` completes onboarding with a lingering dashboard warning. +- [ ] Verify `Install matching version` runs `opencode upgrade `. +- [ ] Verify an upgrade hang or prompt can be canceled and leaves a usable transcript. +- [ ] Verify Local Server hot restart keeps the same port and password within one app launch. +- [ ] Verify Local Server hot restart does not interrupt an active remote session. +- [ ] Verify active Local Server selection follows the new local key after a successful runtime switch. +- [ ] Verify Windows local and WSL local project histories remain separate. +- [ ] Verify the `ConnectionError` CTA opens the Local Server page directly. +- [ ] Verify selected-distro path conversion is used everywhere in WSL mode. +- [ ] Verify selected-distro home is used as the picker default in WSL mode. +- [ ] Verify deleting the selected distro produces an explicit error instead of silent fallback. +- [ ] Verify transcripts are only retained for the current app launch. From 8af7b5cd65a89c7b4fe16b25390e7ffba4a96333 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:40 +1000 Subject: [PATCH 05/88] refactor: scaffold Electron local server config --- .../desktop-electron/src/main/constants.ts | 2 +- packages/desktop-electron/src/main/index.ts | 11 +- packages/desktop-electron/src/main/ipc.ts | 29 ++++- .../desktop-electron/src/main/local-server.ts | 116 ++++++++++++++++++ packages/desktop-electron/src/main/server.ts | 13 +- .../desktop-electron/src/preload/index.ts | 13 +- .../desktop-electron/src/preload/types.ts | 47 ++++++- .../desktop-electron/src/renderer/index.tsx | 10 -- 8 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 packages/desktop-electron/src/main/local-server.ts diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 1e21661c1ad8..4619a336aeae 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -6,5 +6,5 @@ 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 LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 8f21e5b9335d..56c9c943fd1d 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -34,14 +34,15 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg -import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" +import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import type { Server } from "virtual:opencode-server" @@ -55,6 +56,7 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() +const localServer = createLocalServerController() const logger = initLogging() logger.log("app starting", { @@ -231,10 +233,11 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, + getLocalServerState: () => localServer.getState(), + setLocalServerConfig: (config) => localServer.setConfig(config), + onLocalServerEvent: (listener) => localServer.subscribe(listener), 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), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 52d87ed7ee30..c83755188645 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -2,7 +2,15 @@ import { execFile } from "node:child_process" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" -import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" +import type { + InitStep, + LocalServerConfig, + LocalServerEvent, + LocalServerState, + ServerReadyData, + SqliteMigrationProgress, + TitlebarTheme, +} from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -14,10 +22,11 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + getLocalServerState: () => Promise | LocalServerState + setLocalServerConfig: (config: LocalServerConfig) => Promise | void + onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise | void getDisplayBackend: () => Promise setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string @@ -32,17 +41,27 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const offLocalServer = deps.onLocalServerEvent((payload) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send("local-server-event", payload) + } + }) + app.once("will-quit", offLocalServer) + ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) + ipcMain.handle("local-server-get-state", () => deps.getLocalServerState()) + ipcMain.handle("local-server-set-config", (_event: IpcMainInvokeEvent, config: LocalServerConfig) => + deps.setLocalServerConfig(config), + ) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) 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), diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts new file mode 100644 index 000000000000..ee48a6656a4e --- /dev/null +++ b/packages/desktop-electron/src/main/local-server.ts @@ -0,0 +1,116 @@ +import type { LocalServerConfig, LocalServerEvent, LocalServerState, LocalServerStep } from "../preload/types" +import { LOCAL_SERVER_KEY } from "./constants" +import { store } from "./store" + +export function defaultLocalServerConfig(): LocalServerConfig { + return { + mode: "windows", + distro: null, + onboarding: { + step: null, + complete: true, + pendingRestart: false, + }, + acknowledgements: { + root: [], + mismatch: [], + }, + } +} + +export function createLocalServerController() { + let state = toState(readLocalServerConfig()) + const listeners = new Set<(event: LocalServerEvent) => void>() + + const emit = (event: LocalServerEvent) => { + for (const listener of listeners) listener(event) + } + + return { + getState() { + return state + }, + setConfig(config: LocalServerConfig) { + const next = normalizeLocalServerConfig(config) + store.set(LOCAL_SERVER_KEY, next) + state = toState(next, state) + emit({ type: "state", state }) + }, + subscribe(listener: (event: LocalServerEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +function readLocalServerConfig() { + return normalizeLocalServerConfig(store.get(LOCAL_SERVER_KEY)) +} + +function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState { + return { + config, + runtime: { + key: localServerKey(config), + mode: config.mode, + distro: config.distro, + }, + status: current?.status ?? { kind: "idle" }, + job: current?.job ?? null, + } +} + +function normalizeLocalServerConfig(value: unknown): LocalServerConfig { + const fallback = defaultLocalServerConfig() + if (!value || typeof value !== "object") return fallback + const record = value as Record + const mode = record.mode === "wsl" ? "wsl" : "windows" + const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null + return { + mode, + distro, + onboarding: normalizeOnboarding(record.onboarding, mode), + acknowledgements: normalizeAcknowledgements(record.acknowledgements), + } +} + +function normalizeOnboarding(value: unknown, mode: LocalServerConfig["mode"]): LocalServerConfig["onboarding"] { + const record = value && typeof value === "object" ? (value as Record) : {} + return { + step: normalizeStep(record.step), + complete: typeof record.complete === "boolean" ? record.complete : mode === "windows", + pendingRestart: typeof record.pendingRestart === "boolean" ? record.pendingRestart : false, + } +} + +function normalizeAcknowledgements(value: unknown): LocalServerConfig["acknowledgements"] { + const record = value && typeof value === "object" ? (value as Record) : {} + return { + root: Array.isArray(record.root) + ? record.root.filter((item): item is string => typeof item === "string" && item.length > 0) + : [], + mismatch: Array.isArray(record.mismatch) + ? record.mismatch.flatMap((item) => { + if (!item || typeof item !== "object") return [] + const path = typeof item.path === "string" ? item.path : "" + const version = typeof item.version === "string" ? item.version : "" + if (!path || !version) return [] + return [{ path, version }] + }) + : [], + } +} + +function normalizeStep(value: unknown): LocalServerStep | null { + if (value === "wsl") return value + if (value === "distro") return value + if (value === "opencode") return value + if (value === "switch") return value + return null +} + +function localServerKey(config: LocalServerConfig) { + if (config.mode === "windows") return "local:windows" + if (!config.distro) return "local:wsl" + return `local:wsl:${config.distro}` +} diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 55dfdf6e9b10..34014ed5d66c 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,10 +1,8 @@ import { app } 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 } export function getDefaultServerUrl(): string | null { @@ -21,15 +19,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 async function spawnLocalServer(hostname: string, port: number, password: string) { prepareServerEnv(password) const { Log, Server } = await import("virtual:opencode-server") diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 296fcb2f1cc1..d2e21509e5b9 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" +import type { ElectronAPI, InitStep, LocalServerEvent, SqliteMigrationProgress } from "./types" const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), @@ -11,10 +11,17 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, + localServer: { + getState: () => ipcRenderer.invoke("local-server-get-state"), + setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), + subscribe: (cb) => { + const handler = (_: unknown, event: LocalServerEvent) => cb(event) + ipcRenderer.on("local-server-event", handler) + return () => ipcRenderer.removeListener("local-server-event", handler) + }, + }, 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), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index f8e6d52c7db6..6118f318ef7b 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -8,7 +8,49 @@ export type ServerReadyData = { export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type WslConfig = { enabled: boolean } +export type LocalServerMode = "windows" | "wsl" +export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" +export type LocalServerMismatchAcknowledgement = { + path: string + version: string +} +export type LocalServerConfig = { + mode: LocalServerMode + distro: string | null + onboarding: { + step: LocalServerStep | null + complete: boolean + pendingRestart: boolean + } + acknowledgements: { + root: string[] + mismatch: LocalServerMismatchAcknowledgement[] + } +} +export type LocalServerStatus = + | { kind: "idle" } + | { kind: "ready" } + | { kind: "running"; step: LocalServerStep | null } + | { kind: "failed"; step: LocalServerStep | null; message: string } +export type LocalServerState = { + config: LocalServerConfig + runtime: { + key: string + mode: LocalServerMode + distro: string | null + } + status: LocalServerStatus + job: { step: LocalServerStep | null; startedAt: number } | null +} +export type LocalServerEvent = { + type: "state" + state: LocalServerState +} +export type LocalServerAPI = { + getState: () => Promise + setConfig: (config: LocalServerConfig) => Promise + subscribe: (cb: (event: LocalServerEvent) => void) => () => void +} export type LinuxDisplayBackend = "wayland" | "auto" export type TitlebarTheme = { @@ -19,10 +61,9 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise + localServer: LocalServerAPI getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise - getWslConfig: () => Promise - setWslConfig: (config: WslConfig) => Promise getDisplayBackend: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 44f2e6360c35..710fd83e3518 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -194,16 +194,6 @@ const createPlatform = (): Platform => { return fetch(input, init) }, - getWslEnabled: async () => { - const next = await window.api.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__OPENCODE__!.wsl ?? false - }, - - setWslEnabled: async (enabled) => { - await window.api.setWslConfig({ enabled }) - }, - getDefaultServer: async () => { const url = await window.api.getDefaultServerUrl().catch(() => null) if (!url) return null From d6f2b9f1b73fb1fa88d6ec9a81f69a143c32fd53 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:15:00 +1000 Subject: [PATCH 06/88] refactor: give desktop local server a windows key --- packages/app/src/context/server.tsx | 4 ++-- packages/desktop-electron/src/renderer/index.tsx | 2 +- packages/desktop/src/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba55710..8a617df28185 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -23,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals function projectsKey(key: ServerConnection.Key) { if (!key) return "" - if (key === "sidecar") return "local" + if (key === "sidecar" || key === "local:windows") return "local" if (isLocalHost(key)) return "local" return key } @@ -81,7 +81,7 @@ export namespace ServerConnection { return Key.make(conn.http.url) case "sidecar": { if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) - return Key.make("sidecar") + return Key.make("local:windows") } case "ssh": return Key.make(`ssh:${conn.host}`) diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 710fd83e3518..39721307a95b 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -319,7 +319,7 @@ render(() => { {(_) => { return ( diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d6a0ad74f801..875c0bcd606b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -485,7 +485,7 @@ render(() => { {(_) => { return ( From 9d4737fdc9bf28687822b33cee074abb0848d8cf Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:16:22 +1000 Subject: [PATCH 07/88] refactor: track Electron local server startup state --- packages/desktop-electron/src/main/index.ts | 26 ++++++++++-- .../desktop-electron/src/main/local-server.ts | 40 +++++++++++++++---- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 56c9c943fd1d..d3b617f863ae 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -142,7 +142,16 @@ async function initialize() { const password = randomUUID() logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password) + localServer.setRuntime({ key: "local:windows", mode: "windows", distro: null }) + localServer.setStatus({ kind: "running", step: null }) + const { listener, health } = await spawnLocalServer(hostname, port, password).catch((error) => { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + throw error + }) server = listener serverReady.resolve({ url, @@ -169,9 +178,18 @@ async function initialize() { delay(30_000).then(() => { throw new Error("Sidecar health check timed out") }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) + ]) + .then(() => { + localServer.setStatus({ kind: "ready" }) + }) + .catch((error) => { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + logger.error("sidecar health check failed", error) + }) logger.log("loading task finished") })() diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index ee48a6656a4e..843eb1deab0d 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -26,6 +26,11 @@ export function createLocalServerController() { for (const listener of listeners) listener(event) } + const update = (next: LocalServerState) => { + state = next + emit({ type: "state", state }) + } + return { getState() { return state @@ -33,13 +38,27 @@ export function createLocalServerController() { setConfig(config: LocalServerConfig) { const next = normalizeLocalServerConfig(config) store.set(LOCAL_SERVER_KEY, next) - state = toState(next, state) - emit({ type: "state", state }) + update({ + ...state, + config: next, + }) }, subscribe(listener: (event: LocalServerEvent) => void) { listeners.add(listener) return () => listeners.delete(listener) }, + setRuntime(runtime: LocalServerState["runtime"]) { + update({ + ...state, + runtime, + }) + }, + setStatus(status: LocalServerState["status"]) { + update({ + ...state, + status, + }) + }, } } @@ -50,11 +69,7 @@ function readLocalServerConfig() { function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState { return { config, - runtime: { - key: localServerKey(config), - mode: config.mode, - distro: config.distro, - }, + runtime: current?.runtime ?? windowsRuntime(), status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, } @@ -114,3 +129,14 @@ function localServerKey(config: LocalServerConfig) { if (!config.distro) return "local:wsl" return `local:wsl:${config.distro}` } + +function windowsRuntime(): LocalServerState["runtime"] { + return { + key: localServerKey({ + ...defaultLocalServerConfig(), + mode: "windows", + }), + mode: "windows", + distro: null, + } +} From 1a215700c8038f54bd11b8fcd91742e50b607bac Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:17:55 +1000 Subject: [PATCH 08/88] refactor: derive Electron WSL paths from local server config --- packages/desktop-electron/src/main/apps.ts | 11 ++++++--- packages/desktop-electron/src/main/index.ts | 2 +- packages/desktop-electron/src/main/ipc.ts | 8 ++++--- .../desktop-electron/src/preload/index.ts | 2 +- .../desktop-electron/src/preload/types.ts | 2 +- .../desktop-electron/src/renderer/index.tsx | 24 +++++++++++++------ 6 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index 174da94a5d9b..d0d4618c2938 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -13,7 +13,7 @@ export function resolveAppPath(appName: string): string | null { return resolveWindowsAppPath(appName) } -export function wslPath(path: string, mode: "windows" | "linux" | null): string { +export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): string { if (process.platform !== "win32") return path const flag = mode === "windows" ? "-w" : "-u" @@ -21,17 +21,22 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string if (path.startsWith("~")) { const suffix = path.slice(1) const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` - const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd]) + const output = execFileSync("wsl", inDistro(["sh", "-lc", cmd], distro)) return output.toString().trim() } - const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) + const output = execFileSync("wsl", inDistro(["wslpath", flag, path], distro)) return output.toString().trim() } catch (error) { throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } } +function inDistro(args: string[], distro?: string | null) { + if (!distro) return ["-e", ...args] + return ["-d", distro, "--", ...args] +} + function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index d3b617f863ae..c9298e554664 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -260,7 +260,7 @@ registerIpcHandlers({ setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), checkAppExists: async (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), + wslPath: async (path, mode, distro) => wslPath(path, mode, distro), resolveAppPath: async (appName) => resolveAppPath(appName), loadingWindowComplete: () => loadingComplete.resolve(), runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index c83755188645..444fec5cbd65 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -31,7 +31,7 @@ type Deps = { setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string checkAppExists: (appName: string) => Promise | boolean - wslPath: (path: string, mode: "windows" | "linux" | null) => Promise + wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise resolveAppPath: (appName: string) => Promise loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise | void @@ -68,8 +68,10 @@ export function registerIpcHandlers(deps: Deps) { ) 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( + "wsl-path", + (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) => + deps.wslPath(path, mode, distro), ) ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index d2e21509e5b9..d1d20efcce16 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -26,7 +26,7 @@ const api: ElectronAPI = { 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), + wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro), 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), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 6118f318ef7b..704125f34ad4 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -68,7 +68,7 @@ export type ElectronAPI = { setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise checkAppExists: (appName: string) => Promise - wslPath: (path: string, mode: "windows" | "linux" | null) => Promise + wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise resolveAppPath: (appName: string) => Promise storeGet: (name: string, key: string) => Promise storeSet: (name: string, key: string, value: string) => Promise diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 39721307a95b..9ce70e5a99f5 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -57,17 +57,26 @@ const createPlatform = (): Platform => { return undefined })() + const wslDistro = async () => { + if (os !== "windows") return + const state = await window.api.localServer.getState().catch(() => null) + if (state?.config.mode !== "wsl") return + return state.config.distro + } + const wslHome = async () => { - if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined - return window.api.wslPath("~", "windows").catch(() => undefined) + const distro = await wslDistro() + if (!distro) return undefined + return window.api.wslPath("~", "windows", distro).catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__OPENCODE__?.wsl) return result + const distro = await wslDistro() + if (!result || !distro) return result if (Array.isArray(result)) { - return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any + return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any } - return window.api.wslPath(result, "linux").catch(() => result) as any + return window.api.wslPath(result, "linux", distro).catch(() => result) as any } const storage = (() => { @@ -137,8 +146,9 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - if (window.__OPENCODE__?.wsl) { - const converted = await window.api.wslPath(path, "windows").catch(() => null) + const distro = await wslDistro() + if (distro) { + const converted = await window.api.wslPath(path, "windows", distro).catch(() => null) if (converted) return converted } return path From 421293180822911875128514bfdcadc1655dc890 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:24:44 +1000 Subject: [PATCH 09/88] feat: add Electron WSL command helpers --- packages/desktop-electron/src/main/apps.ts | 10 +- packages/desktop-electron/src/main/wsl.ts | 267 +++++++++++++++++++++ 2 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 packages/desktop-electron/src/main/wsl.ts diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index d0d4618c2938..d0fd835b8e6d 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readFileSync, readdirSync } from "node:fs" import { dirname, extname, join } from "node:path" +import { wslArgs } from "./wsl" export function checkAppExists(appName: string): boolean { if (process.platform === "win32") return true @@ -21,22 +22,17 @@ export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: if (path.startsWith("~")) { const suffix = path.slice(1) const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` - const output = execFileSync("wsl", inDistro(["sh", "-lc", cmd], distro)) + const output = execFileSync("wsl", wslArgs(["sh", "-lc", cmd], distro)) return output.toString().trim() } - const output = execFileSync("wsl", inDistro(["wslpath", flag, path], distro)) + const output = execFileSync("wsl", wslArgs(["wslpath", flag, path], distro)) return output.toString().trim() } catch (error) { throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } } -function inDistro(args: string[], distro?: string | null) { - if (!distro) return ["-e", ...args] - return ["-d", distro, "--", ...args] -} - function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts new file mode 100644 index 000000000000..3059e99a0ef5 --- /dev/null +++ b/packages/desktop-electron/src/main/wsl.ts @@ -0,0 +1,267 @@ +import { spawn } from "node:child_process" + +export type WslCommandLine = { + stream: "stdout" | "stderr" + text: string +} + +export type WslCommandResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export type WslRuntimeProbe = { + available: boolean + version: string | null + status: string | null + error: string | null +} + +export type WslInstalledDistro = { + name: string + state: string | null + version: number | null + isDefault: boolean +} + +export type WslOnlineDistro = { + name: string + label: string +} + +export type WslDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + username: string | null + isRoot: boolean | null + error: string | null +} + +type RunWslOptions = { + onLine?: (line: WslCommandLine) => void + signal?: AbortSignal +} + +export function wslArgs(args: string[], distro?: string | null) { + if (distro) return ["-d", distro, "--", ...args] + return ["--", ...args] +} + +export function runWsl(args: string[], opts: RunWslOptions = {}) { + return new Promise((resolve, reject) => { + const child = spawn("wsl", args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + signal: opts.signal, + }) + + let stdout = "" + let stderr = "" + let stdoutPending = "" + let stderrPending = "" + + const flush = (stream: WslCommandLine["stream"], pending: string) => { + if (!pending) return "" + opts.onLine?.({ stream, text: pending }) + return "" + } + + child.stdout.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + 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 }) + }) + child.stdout.on("end", () => { + stdoutPending = flush("stdout", stdoutPending) + }) + + child.stderr.setEncoding("utf8") + child.stderr.on("data", (chunk: string) => { + 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 }) + }) + child.stderr.on("end", () => { + stderrPending = flush("stderr", stderrPending) + }) + + child.once("error", reject) + child.once("close", (code, signal) => { + resolve({ code, signal, stdout, stderr }) + }) + }) +} + +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 function runWslBash(script: string, distro?: string | null, opts?: RunWslOptions) { + return runWslInDistro(["bash", "-lc", script], distro, opts) +} + +export async function probeWslRuntime(): Promise { + const version = await runWsl(["--version"]).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + + if (version.code !== 0) { + return { + available: false, + version: null, + status: null, + error: summarize(version.stderr || version.stdout) || "WSL is unavailable", + } + } + + const status = await runWsl(["--status"]).catch(() => undefined) + return { + available: true, + version: firstLine(version.stdout), + status: status?.code === 0 ? summarize(status.stdout) : null, + error: null, + } +} + +export async function listInstalledWslDistros() { + const result = await runWsl(["--list", "--verbose"]) + 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() { + const result = await runWsl(["--list", "--online"]) + 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 installWslRuntime(opts?: RunWslOptions) { + return runWsl(["--install", "--no-distribution"], opts) +} + +export async function installWslDistro(name: string, opts?: RunWslOptions) { + return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) +} + +export async function probeWslDistro(name: string): Promise { + const executable = await runWslInDistro(["/bin/true"], name).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, + username: null, + isRoot: null, + error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", + } + } + + const [bash, curl, user] = await Promise.all([ + runWslSh("command -v bash >/dev/null && printf yes || printf no", name), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name), + runWslSh("id -un 2>/dev/null || true", name), + ]) + + const username = summarize(user.stdout) + return { + name, + canExecute: true, + hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", + hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", + username: username || null, + isRoot: username ? username === "root" : null, + error: null, + } +} + +export async function resolveWslCommand(command: string, distro: string) { + const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro) + return summarize(result.stdout) || null +} + +export async function readWslCommandVersion(command: string, distro: string) { + const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro) + return firstLine(result.stdout) +} + +export async function upgradeWslOpencode(target: string, distro: string, opts?: RunWslOptions) { + return runWslBash(`opencode upgrade ${shellEscape(target)}`, distro, opts) +} + +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, state, version] = match + if (!name || /^name$/i.test(name)) return [] + return [ + { + name: name.trim(), + state: state || null, + 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 + ) +} + +function summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} From ec1a5c261e034898cedfd8ba079eb529e2f6f4ed Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:27:00 +1000 Subject: [PATCH 10/88] feat: add Electron local server probe actions --- packages/desktop-electron/src/main/index.ts | 2 + packages/desktop-electron/src/main/ipc.ts | 3 + .../desktop-electron/src/main/local-server.ts | 126 +++++++++++++++++- packages/desktop-electron/src/main/wsl.ts | 63 +++------ .../desktop-electron/src/preload/index.ts | 2 + .../desktop-electron/src/preload/types.ts | 37 +++++ 6 files changed, 189 insertions(+), 44 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9298e554664..53675dcebc5d 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -253,6 +253,8 @@ registerIpcHandlers({ }, getLocalServerState: () => localServer.getState(), setLocalServerConfig: (config) => localServer.setConfig(config), + runLocalServerStep: (step) => localServer.runStep(step), + cancelLocalServerJob: () => localServer.cancelJob(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 444fec5cbd65..e8642bacfc1f 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -7,6 +7,7 @@ import type { LocalServerConfig, LocalServerEvent, LocalServerState, + LocalServerStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, @@ -24,6 +25,8 @@ type Deps = { awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getLocalServerState: () => Promise | LocalServerState setLocalServerConfig: (config: LocalServerConfig) => Promise | void + runLocalServerStep: (step: LocalServerStep) => Promise | void + cancelLocalServerJob: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index 843eb1deab0d..e16b881d7ab1 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -1,6 +1,13 @@ -import type { LocalServerConfig, LocalServerEvent, LocalServerState, LocalServerStep } from "../preload/types" +import type { + LocalServerConfig, + LocalServerDistroCheck, + LocalServerEvent, + LocalServerState, + LocalServerStep, +} from "../preload/types" import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" +import { listInstalledWslDistros, listOnlineWslDistros, probeWslDistro, probeWslRuntime } from "./wsl" export function defaultLocalServerConfig(): LocalServerConfig { return { @@ -21,6 +28,7 @@ export function defaultLocalServerConfig(): LocalServerConfig { export function createLocalServerController() { let state = toState(readLocalServerConfig()) const listeners = new Set<(event: LocalServerEvent) => void>() + let jobAbort: AbortController | undefined const emit = (event: LocalServerEvent) => { for (const listener of listeners) listener(event) @@ -47,6 +55,101 @@ export function createLocalServerController() { listeners.add(listener) return () => listeners.delete(listener) }, + async runStep(step: LocalServerStep) { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + update({ + ...state, + job: { step, startedAt: Date.now() }, + status: { kind: "running", step }, + }) + + try { + if (step === "wsl") { + const wsl = await probeWslRuntime({ signal: abort.signal }) + if (jobAbort !== abort) return + update({ + ...state, + job: null, + status: wsl.available + ? { kind: "ready" } + : { kind: "failed", step, message: wsl.error ?? "WSL is unavailable" }, + checks: { + ...state.checks, + wsl, + }, + }) + return + } + + if (step === "distro") { + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal }), + listOnlineWslDistros({ signal: abort.signal }), + ]) + if (jobAbort !== abort) return + + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const selected = state.config.distro + ? await probeWslDistro(state.config.distro, { signal: abort.signal }) + : null + if (jobAbort !== abort) return + + const error = distroError(state.config.distro, installed, selected, installedResult, onlineResult) + const distro: LocalServerDistroCheck = { + installed, + online, + selected, + error, + } + + update({ + ...state, + job: null, + status: error ? { kind: "failed", step, message: error } : { kind: "ready" }, + checks: { + ...state.checks, + distro, + }, + }) + return + } + + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step, message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, + cancelJob() { + jobAbort?.abort() + jobAbort = undefined + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + }, setRuntime(runtime: LocalServerState["runtime"]) { update({ ...state, @@ -72,6 +175,7 @@ function toState(config: LocalServerConfig, current?: LocalServerState): LocalSe runtime: current?.runtime ?? windowsRuntime(), status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, + checks: current?.checks ?? { wsl: null, distro: null }, } } @@ -140,3 +244,23 @@ function windowsRuntime(): LocalServerState["runtime"] { distro: null, } } + +function distroError( + configured: string | null, + installed: LocalServerDistroCheck["installed"], + selected: LocalServerDistroCheck["selected"], + installedResult: PromiseSettledResult, + onlineResult: PromiseSettledResult, +) { + if (installedResult.status === "rejected") { + return installedResult.reason instanceof Error ? installedResult.reason.message : String(installedResult.reason) + } + if (onlineResult.status === "rejected") { + return onlineResult.reason instanceof Error ? onlineResult.reason.message : String(onlineResult.reason) + } + if (configured && !installed.find((item) => item.name === configured)) { + return `Selected distro is not installed: ${configured}` + } + if (selected?.error) return selected.error + return null +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 3059e99a0ef5..e11918d18853 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,4 +1,10 @@ import { spawn } from "node:child_process" +import type { + LocalServerDistroProbe, + LocalServerInstalledDistro, + LocalServerOnlineDistro, + LocalServerWslCheck, +} from "../preload/types" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -12,35 +18,6 @@ export type WslCommandResult = { stderr: string } -export type WslRuntimeProbe = { - available: boolean - version: string | null - status: string | null - error: string | null -} - -export type WslInstalledDistro = { - name: string - state: string | null - version: number | null - isDefault: boolean -} - -export type WslOnlineDistro = { - name: string - label: string -} - -export type WslDistroProbe = { - name: string - canExecute: boolean - hasBash: boolean - hasCurl: boolean - username: string | null - isRoot: boolean | null - error: string | null -} - type RunWslOptions = { onLine?: (line: WslCommandLine) => void signal?: AbortSignal @@ -113,8 +90,8 @@ export function runWslBash(script: string, distro?: string | null, opts?: RunWsl return runWslInDistro(["bash", "-lc", script], distro, opts) } -export async function probeWslRuntime(): Promise { - const version = await runWsl(["--version"]).catch((error) => ({ +export async function probeWslRuntime(opts?: RunWslOptions): Promise { + const version = await runWsl(["--version"], opts).catch((error) => ({ code: 1, signal: null, stdout: "", @@ -130,7 +107,7 @@ export async function probeWslRuntime(): Promise { } } - const status = await runWsl(["--status"]).catch(() => undefined) + const status = await runWsl(["--status"], opts).catch(() => undefined) return { available: true, version: firstLine(version.stdout), @@ -139,16 +116,16 @@ export async function probeWslRuntime(): Promise { } } -export async function listInstalledWslDistros() { - const result = await runWsl(["--list", "--verbose"]) +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() { - const result = await runWsl(["--list", "--online"]) +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") } @@ -163,8 +140,8 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } -export async function probeWslDistro(name: string): Promise { - const executable = await runWslInDistro(["/bin/true"], name).catch((error) => ({ +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { + const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, signal: null, stdout: "", @@ -183,9 +160,9 @@ export async function probeWslDistro(name: string): Promise { } const [bash, curl, user] = await Promise.all([ - runWslSh("command -v bash >/dev/null && printf yes || printf no", name), - runWslSh("command -v curl >/dev/null && printf yes || printf no", name), - runWslSh("id -un 2>/dev/null || true", name), + runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts), + runWslSh("id -un 2>/dev/null || true", name, opts), ]) const username = summarize(user.stdout) @@ -228,7 +205,7 @@ function parseInstalledDistros(output: string) { state: state || null, version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), isDefault: marker === "*", - } satisfies WslInstalledDistro, + } satisfies LocalServerInstalledDistro, ] }) } @@ -241,7 +218,7 @@ function parseOnlineDistros(output: string) { if (!match) return [] const [, name, label] = match if (/^name$/i.test(name)) return [] - return [{ name, label: label.trim() } satisfies WslOnlineDistro] + return [{ name, label: label.trim() } satisfies LocalServerOnlineDistro] }) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index d1d20efcce16..c9dcc9a062e1 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -14,6 +14,8 @@ const api: ElectronAPI = { localServer: { getState: () => ipcRenderer.invoke("local-server-get-state"), setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), + runStep: (step) => ipcRenderer.invoke("local-server-run-step", step), + cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) ipcRenderer.on("local-server-event", handler) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 704125f34ad4..688fc6824d80 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -14,6 +14,37 @@ export type LocalServerMismatchAcknowledgement = { path: string version: string } +export type LocalServerWslCheck = { + available: boolean + version: string | null + status: string | null + error: string | null +} +export type LocalServerInstalledDistro = { + name: string + state: string | null + version: number | null + isDefault: boolean +} +export type LocalServerOnlineDistro = { + name: string + label: string +} +export type LocalServerDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + username: string | null + isRoot: boolean | null + error: string | null +} +export type LocalServerDistroCheck = { + installed: LocalServerInstalledDistro[] + online: LocalServerOnlineDistro[] + selected: LocalServerDistroProbe | null + error: string | null +} export type LocalServerConfig = { mode: LocalServerMode distro: string | null @@ -41,6 +72,10 @@ export type LocalServerState = { } status: LocalServerStatus job: { step: LocalServerStep | null; startedAt: number } | null + checks: { + wsl: LocalServerWslCheck | null + distro: LocalServerDistroCheck | null + } } export type LocalServerEvent = { type: "state" @@ -49,6 +84,8 @@ export type LocalServerEvent = { export type LocalServerAPI = { getState: () => Promise setConfig: (config: LocalServerConfig) => Promise + runStep: (step: LocalServerStep) => Promise + cancelJob: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void } From c0e449d3ac2bd9755e6bb004781ea3faf145cdf8 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:30:25 +1000 Subject: [PATCH 11/88] feat: add local server transcript and terminal action --- packages/desktop-electron/src/main/index.ts | 1 + packages/desktop-electron/src/main/ipc.ts | 6 +++ .../desktop-electron/src/main/local-server.ts | 45 ++++++++++++++++--- packages/desktop-electron/src/main/wsl.ts | 15 +++++++ .../desktop-electron/src/preload/index.ts | 1 + .../desktop-electron/src/preload/types.ts | 7 +++ 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 53675dcebc5d..aa2be07e6f1a 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -255,6 +255,7 @@ registerIpcHandlers({ setLocalServerConfig: (config) => localServer.setConfig(config), runLocalServerStep: (step) => localServer.runStep(step), cancelLocalServerJob: () => localServer.cancelJob(), + openLocalServerTerminal: () => localServer.openTerminal(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index e8642bacfc1f..7021921600da 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -27,6 +27,7 @@ type Deps = { setLocalServerConfig: (config: LocalServerConfig) => Promise | void runLocalServerStep: (step: LocalServerStep) => Promise | void cancelLocalServerJob: () => Promise | void + openLocalServerTerminal: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void @@ -61,6 +62,11 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("local-server-set-config", (_event: IpcMainInvokeEvent, config: LocalServerConfig) => deps.setLocalServerConfig(config), ) + ipcMain.handle("local-server-run-step", (_event: IpcMainInvokeEvent, step: LocalServerStep) => + deps.runLocalServerStep(step), + ) + ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob()) + ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index e16b881d7ab1..48e9c4437833 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -4,10 +4,11 @@ import type { LocalServerEvent, LocalServerState, LocalServerStep, + LocalServerTranscriptLine, } from "../preload/types" import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" -import { listInstalledWslDistros, listOnlineWslDistros, probeWslDistro, probeWslRuntime } from "./wsl" +import { listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime } from "./wsl" export function defaultLocalServerConfig(): LocalServerConfig { return { @@ -39,6 +40,20 @@ export function createLocalServerController() { emit({ type: "state", state }) } + const appendTranscript = (line: Omit) => { + update({ + ...state, + transcript: [...state.transcript, { ...line, at: Date.now() }], + }) + } + + const clearTranscript = () => { + update({ + ...state, + transcript: [], + }) + } + return { getState() { return state @@ -59,6 +74,8 @@ export function createLocalServerController() { jobAbort?.abort() const abort = new AbortController() jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: `Running local server step: ${step}` }) update({ ...state, job: { step, startedAt: Date.now() }, @@ -67,7 +84,10 @@ export function createLocalServerController() { try { if (step === "wsl") { - const wsl = await probeWslRuntime({ signal: abort.signal }) + const wsl = await probeWslRuntime({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) if (jobAbort !== abort) return update({ ...state, @@ -85,15 +105,24 @@ export function createLocalServerController() { if (step === "distro") { const [installedResult, onlineResult] = await Promise.allSettled([ - listInstalledWslDistros({ signal: abort.signal }), - listOnlineWslDistros({ signal: abort.signal }), + listInstalledWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), + listOnlineWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), ]) if (jobAbort !== abort) return const installed = installedResult.status === "fulfilled" ? installedResult.value : [] const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] const selected = state.config.distro - ? await probeWslDistro(state.config.distro, { signal: abort.signal }) + ? await probeWslDistro(state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) : null if (jobAbort !== abort) return @@ -144,12 +173,17 @@ export function createLocalServerController() { cancelJob() { jobAbort?.abort() jobAbort = undefined + appendTranscript({ stream: "system", text: "Canceled local server job" }) update({ ...state, job: null, status: { kind: "idle" }, }) }, + async openTerminal() { + if (!state.config.distro) throw new Error("No WSL distro selected") + await openWslTerminal(state.config.distro) + }, setRuntime(runtime: LocalServerState["runtime"]) { update({ ...state, @@ -176,6 +210,7 @@ function toState(config: LocalServerConfig, current?: LocalServerState): LocalSe status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, checks: current?.checks ?? { wsl: null, distro: null }, + transcript: current?.transcript ?? [], } } diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index e11918d18853..4ec38b9201a9 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -191,6 +191,21 @@ export async function upgradeWslOpencode(target: string, distro: string, opts?: return runWslBash(`opencode upgrade ${shellEscape(target)}`, distro, opts) } +export function openWslTerminal(distro?: string | null) { + return new Promise((resolve, reject) => { + const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", 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() diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index c9dcc9a062e1..321d0fabeba5 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -16,6 +16,7 @@ const api: ElectronAPI = { setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), runStep: (step) => ipcRenderer.invoke("local-server-run-step", step), cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), + openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) ipcRenderer.on("local-server-event", handler) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 688fc6824d80..81a9de3cb29a 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -45,6 +45,11 @@ export type LocalServerDistroCheck = { selected: LocalServerDistroProbe | null error: string | null } +export type LocalServerTranscriptLine = { + stream: "stdout" | "stderr" | "system" + text: string + at: number +} export type LocalServerConfig = { mode: LocalServerMode distro: string | null @@ -76,6 +81,7 @@ export type LocalServerState = { wsl: LocalServerWslCheck | null distro: LocalServerDistroCheck | null } + transcript: LocalServerTranscriptLine[] } export type LocalServerEvent = { type: "state" @@ -86,6 +92,7 @@ export type LocalServerAPI = { setConfig: (config: LocalServerConfig) => Promise runStep: (step: LocalServerStep) => Promise cancelJob: () => Promise + openTerminal: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void } From c575415ec4c07e13c5c990ed5b7b2dc98f33f4fd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:35:58 +1000 Subject: [PATCH 12/88] feat: add local server install actions --- packages/desktop-electron/src/main/index.ts | 2 + packages/desktop-electron/src/main/ipc.ts | 6 + .../desktop-electron/src/main/local-server.ts | 232 +++++++++++++++++- packages/desktop-electron/src/main/wsl.ts | 27 +- .../desktop-electron/src/preload/index.ts | 2 + .../desktop-electron/src/preload/types.ts | 2 + 6 files changed, 263 insertions(+), 8 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index aa2be07e6f1a..edc6a08cde38 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -255,6 +255,8 @@ registerIpcHandlers({ setLocalServerConfig: (config) => localServer.setConfig(config), runLocalServerStep: (step) => localServer.runStep(step), cancelLocalServerJob: () => localServer.cancelJob(), + installLocalServerWsl: () => localServer.installWsl(), + installLocalServerDistro: (name) => localServer.installDistro(name), openLocalServerTerminal: () => localServer.openTerminal(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 7021921600da..5c6a187e9f82 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -27,6 +27,8 @@ type Deps = { setLocalServerConfig: (config: LocalServerConfig) => Promise | void runLocalServerStep: (step: LocalServerStep) => Promise | void cancelLocalServerJob: () => Promise | void + installLocalServerWsl: () => Promise | void + installLocalServerDistro: (name: string) => Promise | void openLocalServerTerminal: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null @@ -66,6 +68,10 @@ export function registerIpcHandlers(deps: Deps) { deps.runLocalServerStep(step), ) ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob()) + ipcMain.handle("local-server-install-wsl", () => deps.installLocalServerWsl()) + ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.installLocalServerDistro(name), + ) ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index 48e9c4437833..d15c83238d8c 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -8,7 +8,16 @@ import type { } from "../preload/types" import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" -import { listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime } from "./wsl" +import { + installWslDistro, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + wslNeedsRestart, +} from "./wsl" export function defaultLocalServerConfig(): LocalServerConfig { return { @@ -54,17 +63,22 @@ export function createLocalServerController() { }) } + const persistConfig = (config: LocalServerConfig) => { + const next = normalizeLocalServerConfig(config) + store.set(LOCAL_SERVER_KEY, next) + update({ + ...state, + config: next, + }) + return next + } + return { getState() { return state }, setConfig(config: LocalServerConfig) { - const next = normalizeLocalServerConfig(config) - store.set(LOCAL_SERVER_KEY, next) - update({ - ...state, - config: next, - }) + persistConfig(config) }, subscribe(listener: (event: LocalServerEvent) => void) { listeners.add(listener) @@ -180,6 +194,201 @@ export function createLocalServerController() { status: { kind: "idle" }, }) }, + async installWsl() { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: "Installing WSL runtime" }) + persistConfig({ + ...state.config, + mode: "wsl", + onboarding: { + ...state.config.onboarding, + step: "wsl", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + job: { step: "wsl", startedAt: Date.now() }, + status: { kind: "running", step: "wsl" }, + }) + + try { + const result = await installWslRuntimeElevated({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, "WSL installation failed")) + + const pendingRestart = wslNeedsRestart(result) + const nextConfig = persistConfig({ + ...state.config, + mode: "wsl", + onboarding: { + ...state.config.onboarding, + step: pendingRestart ? "wsl" : "distro", + complete: false, + pendingRestart, + }, + }) + + if (pendingRestart) { + const message = "Windows restart required to finish WSL installation" + update({ + ...state, + config: nextConfig, + job: null, + status: { kind: "failed", step: "wsl", message }, + checks: { + ...state.checks, + wsl: { + available: false, + version: null, + status: null, + error: message, + }, + }, + }) + return + } + + const wsl = await probeWslRuntime({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + update({ + ...state, + config: nextConfig, + job: null, + status: wsl.available + ? { kind: "ready" } + : { kind: "failed", step: "wsl", message: wsl.error ?? "WSL is unavailable" }, + checks: { + ...state.checks, + wsl, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "wsl", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, + async installDistro(name: string) { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) + persistConfig({ + ...state.config, + mode: "wsl", + distro: name, + onboarding: { + ...state.config.onboarding, + step: "distro", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + job: { step: "distro", startedAt: Date.now() }, + status: { kind: "running", step: "distro" }, + }) + + try { + const result = await installWslDistro(name, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, `Failed to install distro: ${name}`)) + + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), + listOnlineWslDistros({ + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }), + ]) + if (jobAbort !== abort) return + + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const selected = await probeWslDistro(name, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + + const error = distroError(name, installed, selected, installedResult, onlineResult) + const nextConfig = persistConfig({ + ...state.config, + mode: "wsl", + distro: name, + onboarding: { + ...state.config.onboarding, + step: error ? "distro" : "opencode", + complete: false, + pendingRestart: false, + }, + }) + update({ + ...state, + config: nextConfig, + job: null, + status: error ? { kind: "failed", step: "distro", message: error } : { kind: "ready" }, + checks: { + ...state.checks, + distro: { + installed, + online, + selected, + error, + }, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "distro", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, async openTerminal() { if (!state.config.distro) throw new Error("No WSL distro selected") await openWslTerminal(state.config.distro) @@ -299,3 +508,12 @@ function distroError( if (selected?.error) return selected.error return null } + +function commandFailure(result: { stdout: string; stderr: string }, fallback: string) { + const output = `${result.stderr}\n${result.stdout}` + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") + return output || fallback +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 4ec38b9201a9..314f7bfd2817 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -29,8 +29,20 @@ export function wslArgs(args: string[], distro?: string | null) { } 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((resolve, reject) => { - const child = spawn("wsl", args, { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, signal: opts.signal, @@ -136,10 +148,23 @@ export async function installWslRuntime(opts?: RunWslOptions) { return runWsl(["--install", "--no-distribution"], opts) } +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, opts) +} + export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } +export function wslNeedsRestart(result: WslCommandResult) { + return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) +} + export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 321d0fabeba5..9b69a571e9b9 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -16,6 +16,8 @@ const api: ElectronAPI = { setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), runStep: (step) => ipcRenderer.invoke("local-server-run-step", step), cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), + installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), + installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 81a9de3cb29a..ec4de4702391 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -92,6 +92,8 @@ export type LocalServerAPI = { setConfig: (config: LocalServerConfig) => Promise runStep: (step: LocalServerStep) => Promise cancelJob: () => Promise + installWsl: () => Promise + installDistro: (name: string) => Promise openTerminal: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void } From df635562e919812f5d8aa876380acb09958e1704 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:37:21 +1000 Subject: [PATCH 13/88] refactor: expose local server through app platform --- packages/app/src/context/platform.tsx | 93 +++++++++++++++++++ packages/app/src/index.ts | 12 ++- .../desktop-electron/src/renderer/index.tsx | 11 +++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3bdc46391b67..38c742301b2b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,6 +9,96 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +export type LocalServerMode = "windows" | "wsl" +export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" +export type LocalServerMismatchAcknowledgement = { + path: string + version: string +} +export type LocalServerWslCheck = { + available: boolean + version: string | null + status: string | null + error: string | null +} +export type LocalServerInstalledDistro = { + name: string + state: string | null + version: number | null + isDefault: boolean +} +export type LocalServerOnlineDistro = { + name: string + label: string +} +export type LocalServerDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + username: string | null + isRoot: boolean | null + error: string | null +} +export type LocalServerDistroCheck = { + installed: LocalServerInstalledDistro[] + online: LocalServerOnlineDistro[] + selected: LocalServerDistroProbe | null + error: string | null +} +export type LocalServerTranscriptLine = { + stream: "stdout" | "stderr" | "system" + text: string + at: number +} +export type LocalServerConfig = { + mode: LocalServerMode + distro: string | null + onboarding: { + step: LocalServerStep | null + complete: boolean + pendingRestart: boolean + } + acknowledgements: { + root: string[] + mismatch: LocalServerMismatchAcknowledgement[] + } +} +export type LocalServerStatus = + | { kind: "idle" } + | { kind: "ready" } + | { kind: "running"; step: LocalServerStep | null } + | { kind: "failed"; step: LocalServerStep | null; message: string } +export type LocalServerState = { + config: LocalServerConfig + runtime: { + key: string + mode: LocalServerMode + distro: string | null + } + status: LocalServerStatus + job: { step: LocalServerStep | null; startedAt: number } | null + checks: { + wsl: LocalServerWslCheck | null + distro: LocalServerDistroCheck | null + } + transcript: LocalServerTranscriptLine[] +} +export type LocalServerEvent = { + type: "state" + state: LocalServerState +} +export type LocalServerPlatform = { + getState(): Promise + setConfig(config: LocalServerConfig): Promise + runStep(step: LocalServerStep): Promise + cancelJob(): Promise + installWsl(): Promise + installDistro(name: string): Promise + openTerminal(): Promise + subscribe(cb: (event: LocalServerEvent) => void): () => void +} + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" @@ -64,6 +154,9 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void + /** Manage the desktop Local Server lifecycle (desktop only) */ + localServer?: LocalServerPlatform + /** Get the configured WSL integration (desktop only) */ getWslEnabled?(): Promise diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d80e9fffb0af..4e01e764fe56 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -2,6 +2,16 @@ 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 Platform, PlatformProvider } from "./context/platform" +export { + type DisplayBackend, + type LocalServerConfig, + type LocalServerEvent, + type LocalServerMode, + type LocalServerPlatform, + type LocalServerState, + type LocalServerStep, + type Platform, + PlatformProvider, +} from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 9ce70e5a99f5..1b3c40934010 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -214,6 +214,17 @@ const createPlatform = (): Platform => { await window.api.setDefaultServerUrl(url) }, + localServer: { + getState: () => window.api.localServer.getState(), + setConfig: (config) => window.api.localServer.setConfig(config), + runStep: (step) => window.api.localServer.runStep(step), + cancelJob: () => window.api.localServer.cancelJob(), + installWsl: () => window.api.localServer.installWsl(), + installDistro: (name) => window.api.localServer.installDistro(name), + openTerminal: () => window.api.localServer.openTerminal(), + subscribe: (cb) => window.api.localServer.subscribe(cb), + }, + getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) }, From 8d8e8fe8f4d972af47271ad361c6ff6e969ecac0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:40:43 +1000 Subject: [PATCH 14/88] feat: add local server management dialog shell --- .../src/components/dialog-local-server.tsx | 276 ++++++++++++++++++ .../src/components/dialog-select-server.tsx | 273 ++++++++++------- 2 files changed, 436 insertions(+), 113 deletions(-) create mode 100644 packages/app/src/components/dialog-local-server.tsx diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx new file mode 100644 index 000000000000..a810415079a8 --- /dev/null +++ b/packages/app/src/components/dialog-local-server.tsx @@ -0,0 +1,276 @@ +import { Button } from "@opencode-ai/ui/button" +import { showToast } from "@opencode-ai/ui/toast" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useLanguage } from "@/context/language" +import type { LocalServerState } from "@/context/platform" +import { usePlatform } from "@/context/platform" + +export function DialogLocalServer() { + const language = useLanguage() + const platform = usePlatform() + const [store, setStore] = createStore({ + state: undefined as LocalServerState | undefined, + loading: true, + }) + + createEffect(() => { + const localServer = platform.localServer + if (!localServer) return + let mounted = true + void localServer + .getState() + .then((state) => { + if (!mounted) return + setStore({ state, loading: false }) + }) + .catch((err) => { + if (!mounted) return + requestError(language, err) + setStore("loading", false) + }) + const off = localServer.subscribe((event) => { + setStore("state", reconcile(event.state)) + setStore("loading", false) + }) + onCleanup(() => { + mounted = false + off() + }) + }) + + const current = () => store.state + const localServer = () => platform.localServer + const busy = createMemo(() => !!current()?.job) + const mode = createMemo(() => current()?.config.mode ?? "windows") + const selected = createMemo(() => current()?.checks.distro?.selected) + + const run = async (action: () => Promise) => { + try { + await action() + } catch (err) { + requestError(language, err) + } + } + + const setMode = async (next: "windows" | "wsl") => { + const state = current() + if (!state || !localServer()) return + await run(() => + localServer()!.setConfig({ + ...state.config, + mode: next, + onboarding: { + ...state.config.onboarding, + complete: next === "windows", + pendingRestart: next === "windows" ? false : state.config.onboarding.pendingRestart, + step: next === "windows" ? null : state.config.onboarding.step, + }, + }), + ) + } + + const selectDistro = async (name: string) => { + const state = current() + if (!state || !localServer()) return + await run(() => + localServer()!.setConfig({ + ...state.config, + mode: "wsl", + distro: name, + onboarding: { + ...state.config.onboarding, + complete: false, + step: "distro", + }, + }), + ) + } + + return ( +
+ Loading local server...
} + > +
+
Runtime
+
+ + +
+
+ Current runtime:{" "} + {current()?.runtime.mode === "wsl" + ? `wsl${current()?.runtime.distro ? `:${current()?.runtime.distro}` : ""}` + : "windows"} +
+
+ + +
+
+
+
WSL
+
+ {current()?.checks.wsl?.error ?? + current()?.checks.wsl?.status ?? + current()?.checks.wsl?.version ?? + "Not checked yet"} +
+
+
+ + +
+
+ +
+ Windows restart required to finish WSL installation. +
+
+
+ +
+
+
+
Distro
+
+ {current()?.checks.distro?.error ?? + selected()?.name ?? + current()?.config.distro ?? + "No distro selected"} +
+
+
+ +
+
+ +
+ + +
+ +
+
Installed distros
+ 0} + fallback={
No distros detected yet.
} + > + + {(item) => ( + + )} + +
+
+ + + {(probe) => ( +
+
Selected distro checks
+
+ User: {probe().username ?? "unknown"} + {probe().isRoot ? " · root" : ""} +
+
+ bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} + {probe().canExecute ? "yes" : "no"} +
+
+ )} +
+ +
+ +
+
+
+ + 0}> +
+
Diagnostics
+
+ {(line) =>
{line.text}
}
+
+
+
+ + + ) +} + +function requestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index dd92edec3ee2..63efa2b275d6 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -11,6 +11,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" +import { DialogLocalServer } from "@/components/dialog-local-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -191,6 +192,9 @@ export function DialogSelectServer() { showForm: false, status: undefined as boolean | undefined, }, + localServer: { + showPage: false, + }, editServer: { id: undefined as string | undefined, value: "", @@ -419,7 +423,8 @@ export function DialogSelectServer() { ) } - const mode = createMemo<"list" | "add" | "edit">(() => { + const mode = createMemo<"list" | "local" | "add" | "edit">(() => { + if (store.localServer.showPage) return "local" if (store.editServer.id) return "edit" if (store.addServer.showForm) return "add" return "list" @@ -433,9 +438,11 @@ export function DialogSelectServer() { const resetForm = () => { resetAdd() resetEdit() + setStore("localServer", "showPage", false) } const startAdd = () => { + setStore("localServer", "showPage", false) resetEdit() setStore("addServer", { showForm: true, @@ -449,6 +456,7 @@ export function DialogSelectServer() { } const startEdit = (conn: ServerConnection.Http) => { + setStore("localServer", "showPage", false) resetAdd() setStore("editServer", { id: conn.http.url, @@ -461,6 +469,12 @@ export function DialogSelectServer() { }) } + const startLocal = () => { + resetAdd() + resetEdit() + setStore("localServer", "showPage", true) + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -477,6 +491,7 @@ export function DialogSelectServer() { const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") + const isLocalMode = createMemo(() => mode() === "local") const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formTitle = createMemo(() => { @@ -484,7 +499,13 @@ export function DialogSelectServer() { return (
- {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} + + {isLocalMode() + ? "Local Server" + : isAddMode() + ? language.t("dialog.server.add.title") + : language.t("dialog.server.edit.title")} +
) }) @@ -508,130 +529,156 @@ export function DialogSelectServer() { + + } + > + + } > - x.http.url} - onSelect={(x) => { - if (x) void select(x) - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" - > - {(i) => { - const key = ServerConnection.key(i) - return ( -
-
- -
- - - {language.t("dialog.server.status.default")} - +
+ +
+ +
+
+ + x.http.url} + onSelect={(x) => { + if (x) void select(x) + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + + } + showCredentials + /> +
+ + - } - showCredentials - /> -
- - - - - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - - setDefault(key)}> - - {language.t("dialog.server.menu.default")} - + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} - - - setDefault(null)}> + + setDefault(key)}> + + {language.t("dialog.server.menu.default")} + + + + + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > - {language.t("dialog.server.menu.defaultRemove")} + {language.t("dialog.server.menu.delete")} - - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - - - - - + + + + +
-
- ) - }} -
+ ) + }} + +
- {language.t("dialog.server.add.button")} - + + + } > +
0}>
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 63efa2b275d6..caefa2cf94a4 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -20,6 +20,10 @@ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" +interface DialogSelectServerProps { + initialView?: "list" | "local" +} + interface ServerFormProps { value: string name: string @@ -172,7 +176,7 @@ function ServerForm(props: ServerFormProps) { ) } -export function DialogSelectServer() { +export function DialogSelectServer(props: DialogSelectServerProps = {}) { const navigate = useNavigate() const dialog = useDialog() const server = useServer() @@ -193,7 +197,7 @@ export function DialogSelectServer() { status: undefined as boolean | undefined, }, localServer: { - showPage: false, + showPage: props.initialView === "local", }, editServer: { id: undefined as string | undefined, From 710469cf098ad8ffc44f8239447ca238b20774a9 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:45:35 +1000 Subject: [PATCH 16/88] feat: add local server opencode setup flow --- .../src/components/dialog-local-server.tsx | 50 ++++++ packages/app/src/context/platform.tsx | 9 ++ packages/app/src/index.ts | 1 + packages/desktop-electron/src/main/index.ts | 3 +- packages/desktop-electron/src/main/ipc.ts | 2 + .../desktop-electron/src/main/local-server.ts | 142 +++++++++++++++++- packages/desktop-electron/src/main/wsl.ts | 16 +- .../desktop-electron/src/preload/index.ts | 1 + .../desktop-electron/src/preload/types.ts | 9 ++ .../desktop-electron/src/renderer/index.tsx | 1 + 10 files changed, 227 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index a810415079a8..8f41c90c9414 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -252,6 +252,56 @@ export function DialogLocalServer() {
+ + +
+
+
+
OpenCode
+
+ {current()?.checks.opencode?.error ?? current()?.checks.opencode?.resolvedPath ?? "Not checked yet"} +
+
+
+ + +
+
+ + + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` · desktop ${expected()}`}} + +
+ +
+ Installed version does not match the desktop app version. +
+
+
+ )} +
+
+
0}> diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 38c742301b2b..80b942dc846f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -46,6 +46,13 @@ export type LocalServerDistroCheck = { selected: LocalServerDistroProbe | null error: string | null } +export type LocalServerOpencodeCheck = { + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} export type LocalServerTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string @@ -81,6 +88,7 @@ export type LocalServerState = { checks: { wsl: LocalServerWslCheck | null distro: LocalServerDistroCheck | null + opencode: LocalServerOpencodeCheck | null } transcript: LocalServerTranscriptLine[] } @@ -95,6 +103,7 @@ export type LocalServerPlatform = { cancelJob(): Promise installWsl(): Promise installDistro(name: string): Promise + installOpencode(): Promise openTerminal(): Promise subscribe(cb: (event: LocalServerEvent) => void): () => void } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 4e01e764fe56..9a11def8fdc5 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -7,6 +7,7 @@ export { type LocalServerConfig, type LocalServerEvent, type LocalServerMode, + type LocalServerOpencodeCheck, type LocalServerPlatform, type LocalServerState, type LocalServerStep, diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index edc6a08cde38..d5d92ee07b09 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -56,7 +56,7 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() -const localServer = createLocalServerController() +const localServer = createLocalServerController(app.getVersion()) const logger = initLogging() logger.log("app starting", { @@ -257,6 +257,7 @@ registerIpcHandlers({ cancelLocalServerJob: () => localServer.cancelJob(), installLocalServerWsl: () => localServer.installWsl(), installLocalServerDistro: (name) => localServer.installDistro(name), + installLocalServerOpencode: () => localServer.installOpencode(), openLocalServerTerminal: () => localServer.openTerminal(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 5c6a187e9f82..8c325bb6d663 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -29,6 +29,7 @@ type Deps = { cancelLocalServerJob: () => Promise | void installLocalServerWsl: () => Promise | void installLocalServerDistro: (name: string) => Promise | void + installLocalServerOpencode: () => Promise | void openLocalServerTerminal: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null @@ -72,6 +73,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) => deps.installLocalServerDistro(name), ) + ipcMain.handle("local-server-install-opencode", () => deps.installLocalServerOpencode()) ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index d15c83238d8c..3c4f5d111b6d 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -2,6 +2,7 @@ import type { LocalServerConfig, LocalServerDistroCheck, LocalServerEvent, + LocalServerOpencodeCheck, LocalServerState, LocalServerStep, LocalServerTranscriptLine, @@ -10,12 +11,16 @@ import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" import { installWslDistro, + installWslOpencode, installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime, + readWslCommandVersion, + resolveWslCommand, + upgradeWslOpencode, wslNeedsRestart, } from "./wsl" @@ -35,7 +40,7 @@ export function defaultLocalServerConfig(): LocalServerConfig { } } -export function createLocalServerController() { +export function createLocalServerController(appVersion: string) { let state = toState(readLocalServerConfig()) const listeners = new Set<(event: LocalServerEvent) => void>() let jobAbort: AbortController | undefined @@ -160,6 +165,42 @@ export function createLocalServerController() { return } + if (step === "opencode") { + if (!state.config.distro) { + update({ + ...state, + job: null, + status: { kind: "failed", step, message: "No WSL distro selected" }, + }) + return + } + + const resolvedPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + const version = resolvedPath + ? await readWslCommandVersion(resolvedPath, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : null + if (jobAbort !== abort) return + + const opencode = opencodeCheck(resolvedPath, version, appVersion) + update({ + ...state, + job: null, + status: opencode.error ? { kind: "failed", step, message: opencode.error } : { kind: "ready" }, + checks: { + ...state.checks, + opencode, + }, + }) + return + } + update({ ...state, job: null, @@ -389,6 +430,80 @@ export function createLocalServerController() { if (jobAbort === abort) jobAbort = undefined } }, + async installOpencode() { + if (!state.config.distro) throw new Error("No WSL distro selected") + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: `Installing OpenCode in ${state.config.distro}` }) + update({ + ...state, + job: { step: "opencode", startedAt: Date.now() }, + status: { kind: "running", step: "opencode" }, + }) + + try { + const resolvedPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + + const result = resolvedPath + ? await upgradeWslOpencode(appVersion, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : await installWslOpencode(appVersion, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, "OpenCode installation failed")) + + const nextPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + const version = nextPath + ? await readWslCommandVersion(nextPath, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : null + if (jobAbort !== abort) return + + const opencode = opencodeCheck(nextPath, version, appVersion) + update({ + ...state, + job: null, + status: opencode.error ? { kind: "failed", step: "opencode", message: opencode.error } : { kind: "ready" }, + checks: { + ...state.checks, + opencode, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "opencode", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, async openTerminal() { if (!state.config.distro) throw new Error("No WSL distro selected") await openWslTerminal(state.config.distro) @@ -418,7 +533,7 @@ function toState(config: LocalServerConfig, current?: LocalServerState): LocalSe runtime: current?.runtime ?? windowsRuntime(), status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, - checks: current?.checks ?? { wsl: null, distro: null }, + checks: current?.checks ?? { wsl: null, distro: null, opencode: null }, transcript: current?.transcript ?? [], } } @@ -517,3 +632,26 @@ function commandFailure(result: { stdout: string; stderr: string }, fallback: st .join("\n") return output || fallback } + +function opencodeCheck( + resolvedPath: string | null, + version: string | null, + expectedVersion: string, +): LocalServerOpencodeCheck { + if (!resolvedPath) { + return { + resolvedPath: null, + version: null, + expectedVersion, + matchesDesktop: null, + error: "opencode is not installed in the selected distro", + } + } + return { + resolvedPath, + version, + expectedVersion, + matchesDesktop: version ? version === expectedVersion : null, + error: null, + } +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 314f7bfd2817..a0f6e978524c 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -161,6 +161,14 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } +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, + ) +} + export function wslNeedsRestart(result: WslCommandResult) { return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) } @@ -202,13 +210,13 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } -export async function resolveWslCommand(command: string, distro: string) { - const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro) +export async function resolveWslCommand(command: string, distro: string, opts?: RunWslOptions) { + const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro, opts) return summarize(result.stdout) || null } -export async function readWslCommandVersion(command: string, distro: string) { - const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro) +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) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 9b69a571e9b9..3d7db1e6d9a4 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -18,6 +18,7 @@ const api: ElectronAPI = { cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), + installOpencode: () => ipcRenderer.invoke("local-server-install-opencode"), openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ec4de4702391..f2bd4adce366 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -45,6 +45,13 @@ export type LocalServerDistroCheck = { selected: LocalServerDistroProbe | null error: string | null } +export type LocalServerOpencodeCheck = { + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} export type LocalServerTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string @@ -80,6 +87,7 @@ export type LocalServerState = { checks: { wsl: LocalServerWslCheck | null distro: LocalServerDistroCheck | null + opencode: LocalServerOpencodeCheck | null } transcript: LocalServerTranscriptLine[] } @@ -94,6 +102,7 @@ export type LocalServerAPI = { cancelJob: () => Promise installWsl: () => Promise installDistro: (name: string) => Promise + installOpencode: () => Promise openTerminal: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 1b3c40934010..3cd3124396e4 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -221,6 +221,7 @@ const createPlatform = (): Platform => { cancelJob: () => window.api.localServer.cancelJob(), installWsl: () => window.api.localServer.installWsl(), installDistro: (name) => window.api.localServer.installDistro(name), + installOpencode: () => window.api.localServer.installOpencode(), openTerminal: () => window.api.localServer.openTerminal(), subscribe: (cb) => window.api.localServer.subscribe(cb), }, From 482dc3a15d5289629c2987961114823d86f1c834 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:46:48 +1000 Subject: [PATCH 17/88] refactor: carry local runtime metadata through startup --- packages/desktop-electron/src/main/index.ts | 2 ++ packages/desktop-electron/src/preload/types.ts | 5 +++++ packages/desktop-electron/src/renderer/index.tsx | 8 ++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index d5d92ee07b09..a2763e356e9a 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -153,10 +153,12 @@ async function initialize() { throw error }) server = listener + const runtime = localServer.getState().runtime serverReady.resolve({ url, username: "opencode", password, + local: runtime, }) const loadingTask = (async () => { diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index f2bd4adce366..45c6ead54d08 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -4,6 +4,11 @@ export type ServerReadyData = { url: string username: string | null password: string | null + local: { + key: string + mode: LocalServerMode + distro: string | null + } } export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 3cd3124396e4..330f90ccca2b 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -291,7 +291,9 @@ render(() => { const server: ServerConnection.Sidecar = { displayName: "Local Server", type: "sidecar", - variant: "base", + ...(data.local.mode === "wsl" && data.local.distro + ? { variant: "wsl", distro: data.local.distro } + : { variant: "base" }), http: { url: data.url, username: data.username ?? undefined, @@ -341,7 +343,9 @@ render(() => { {(_) => { return ( From 5aa544179dc5070e875c98abf1b223ae1e605082 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:48:38 +1000 Subject: [PATCH 18/88] feat: branch Electron local startup by runtime --- packages/desktop-electron/src/main/index.ts | 82 +++++++++++++------- packages/desktop-electron/src/main/server.ts | 57 ++++++++++++++ 2 files changed, 109 insertions(+), 30 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index a2763e356e9a..400fe8bdf2fb 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -42,15 +42,14 @@ import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer } from "./server" +import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" -import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: { stop(): void } | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -140,26 +139,47 @@ async function initialize() { const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() + const config = localServer.getState().config + const runtime = + config.mode === "wsl" && config.distro + ? { + key: `local:wsl:${config.distro}`, + mode: "wsl" as const, + distro: config.distro, + } + : { + key: "local:windows", + mode: "windows" as const, + distro: null, + } logger.log("spawning sidecar", { url }) - localServer.setRuntime({ key: "local:windows", mode: "windows", distro: null }) + localServer.setRuntime(runtime) localServer.setStatus({ kind: "running", step: null }) - const { listener, health } = await spawnLocalServer(hostname, port, password).catch((error) => { - localServer.setStatus({ - kind: "failed", - step: null, - message: error instanceof Error ? error.message : String(error), - }) - throw error - }) - server = listener - const runtime = localServer.getState().runtime serverReady.resolve({ url, username: "opencode", password, local: runtime, }) + const startup = await (async () => { + try { + if (runtime.mode === "wsl") { + if (!runtime.distro) throw new Error("No WSL distro selected") + return spawnWslLocalServer(runtime.distro, port, password) + } + return spawnLocalServer(hostname, port, password) + } catch (error) { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + logger.error("local server startup failed", error) + return undefined + } + })() + server = startup?.listener ?? null const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -175,23 +195,25 @@ async function initialize() { await sqliteDone?.promise } - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]) - .then(() => { - localServer.setStatus({ kind: "ready" }) - }) - .catch((error) => { - localServer.setStatus({ - kind: "failed", - step: null, - message: error instanceof Error ? error.message : String(error), + if (startup) { + await Promise.race([ + startup.health.wait, + delay(30_000).then(() => { + throw new Error("Sidecar health check timed out") + }), + ]) + .then(() => { + localServer.setStatus({ kind: "ready" }) }) - logger.error("sidecar health check failed", error) - }) + .catch((error) => { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + logger.error("sidecar health check failed", error) + }) + } logger.log("loading task finished") })() diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 34014ed5d66c..b367de9db3d9 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,7 +1,9 @@ +import { spawn } from "node:child_process" import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" +import { wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -46,6 +48,57 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } +export async function spawnWslLocalServer(distro: string, port: number, password: string) { + const script = [ + "set -e", + "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", + "OPENCODE_EXPERIMENTAL_FILEWATCHER=true", + "OPENCODE_CLIENT=desktop", + `OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`, + `OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, + 'XDG_STATE_HOME="$HOME/.local/state"', + `exec opencode --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`, + ].join(" ") + + const child = spawn("wsl", wslArgs(["bash", "-lc", script], distro), { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + + const exit = new Promise((_, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => { + reject( + new Error( + `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})`, + ), + ) + }) + }) + + const wait = Promise.race([ + (async () => { + const url = `http://127.0.0.1:${port}` + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (await checkHealth(url, password)) return + } + })(), + exit, + ]) + + return { + listener: { + stop() { + child.kill() + }, + }, + health: { wait }, + } +} + function prepareServerEnv(password: string) { const shell = process.platform === "win32" ? null : getUserShell() const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} @@ -62,6 +115,10 @@ function prepareServerEnv(password: string) { Object.assign(process.env, env) } +function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL try { From 58ab95e32cfe172dd707d5aa84250028ff7f5432 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:49:35 +1000 Subject: [PATCH 19/88] feat: add restart-to-apply local runtime changes --- .../src/components/dialog-local-server.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 8f41c90c9414..305ada58fcd5 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -44,6 +44,19 @@ export function DialogLocalServer() { const busy = createMemo(() => !!current()?.job) const mode = createMemo(() => current()?.config.mode ?? "windows") const selected = createMemo(() => current()?.checks.distro?.selected) + const configuredRuntime = createMemo(() => { + const state = current() + if (!state) return { mode: "windows" as const, distro: null as string | null } + if (state.config.mode === "wsl" && state.config.distro) { + return { mode: "wsl" as const, distro: state.config.distro } + } + return { mode: "windows" as const, distro: null as string | null } + }) + const needsRestart = createMemo(() => { + const state = current() + if (!state) return false + return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro + }) const run = async (action: () => Promise) => { try { @@ -117,6 +130,14 @@ export function DialogLocalServer() { ? `wsl${current()?.runtime.distro ? `:${current()?.runtime.distro}` : ""}` : "windows"} + +
+
Restart OpenCode to apply local runtime changes.
+ +
+
From 6f2a6356ed92b16be145eb42bd61f95946187596 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:00:02 +1000 Subject: [PATCH 20/88] fix: surface desktop renderer errors in logs --- .../src/components/dialog-local-server.tsx | 1 + packages/desktop-electron/src/main/index.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 305ada58fcd5..cc6fed467295 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -339,6 +339,7 @@ export function DialogLocalServer() { } function requestError(language: ReturnType, err: unknown) { + console.error("Local Server request failed", err) showToast({ variant: "error", title: language.t("common.requestFailed"), diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 400fe8bdf2fb..cc2b7a8e7f0f 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -69,6 +69,14 @@ function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + process.on("uncaughtException", (error) => { + logger.error("main process uncaught exception", error) + }) + + process.on("unhandledRejection", (reason) => { + logger.error("main process unhandled rejection", reason) + }) + if (!app.requestSingleInstanceLock()) { app.quit() return @@ -227,6 +235,7 @@ async function initialize() { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { overlay = createLoadingWindow(globals) + wireWindowDiagnostics(overlay, "loading") await delay(1_000) } } @@ -239,11 +248,51 @@ async function initialize() { } mainWindow = createMainWindow(globals) + wireWindowDiagnostics(mainWindow, "main") wireMenu() overlay?.close() } +function wireWindowDiagnostics(win: BrowserWindow, label: string) { + win.webContents.on("console-message", (_event, level, message, line, sourceId) => { + const payload = { level, message, line, sourceId } + if (level >= 3) { + logger.error(`${label} renderer console`, payload) + return + } + if (level >= 2) { + logger.warn(`${label} renderer console`, payload) + return + } + logger.log(`${label} renderer console`, payload) + }) + + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + logger.error(`${label} renderer failed load`, { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + }) + }) + + win.webContents.on("render-process-gone", (_event, details) => { + logger.error(`${label} renderer process gone`, details) + }) + + win.webContents.on("preload-error", (_event, path, error) => { + logger.error(`${label} preload error`, { + path, + error: error instanceof Error ? (error.stack ?? error.message) : String(error), + }) + }) + + win.on("unresponsive", () => { + logger.error(`${label} window became unresponsive`) + }) +} + function wireMenu() { if (!mainWindow) return createMenu({ From 08d422dca1ba41f70a04efeca80478e88a7fa48e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:00:46 +1000 Subject: [PATCH 21/88] fix: include local server stack traces in renderer logs --- packages/app/src/components/dialog-local-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index cc6fed467295..10032a0bb213 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -339,7 +339,7 @@ export function DialogLocalServer() { } function requestError(language: ReturnType, err: unknown) { - console.error("Local Server request failed", err) + console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) showToast({ variant: "error", title: language.t("common.requestFailed"), From c1dc769b5fa8d469988b32cddc69f066d0f4e039 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:02:11 +1000 Subject: [PATCH 22/88] fix: send plain local server config over ipc --- .../src/components/dialog-local-server.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 10032a0bb213..aa2e0a4deaca 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -1,9 +1,9 @@ import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { createStore, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerState } from "@/context/platform" +import type { LocalServerConfig, LocalServerState } from "@/context/platform" import { usePlatform } from "@/context/platform" export function DialogLocalServer() { @@ -66,18 +66,21 @@ export function DialogLocalServer() { } } + const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(unwrap(config)) + const setMode = async (next: "windows" | "wsl") => { const state = current() if (!state || !localServer()) return + const config = plainConfig(state.config) await run(() => localServer()!.setConfig({ - ...state.config, + ...config, mode: next, onboarding: { - ...state.config.onboarding, + ...config.onboarding, complete: next === "windows", - pendingRestart: next === "windows" ? false : state.config.onboarding.pendingRestart, - step: next === "windows" ? null : state.config.onboarding.step, + pendingRestart: next === "windows" ? false : config.onboarding.pendingRestart, + step: next === "windows" ? null : config.onboarding.step, }, }), ) @@ -86,13 +89,14 @@ export function DialogLocalServer() { const selectDistro = async (name: string) => { const state = current() if (!state || !localServer()) return + const config = plainConfig(state.config) await run(() => localServer()!.setConfig({ - ...state.config, + ...config, mode: "wsl", distro: name, onboarding: { - ...state.config.onboarding, + ...config.onboarding, complete: false, step: "distro", }, From 2cd61113c1a3404f3f9d379dd59614d560673091 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:03:05 +1000 Subject: [PATCH 23/88] feat: show other distro install options --- .../src/components/dialog-local-server.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index aa2e0a4deaca..524fbfb06ad9 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -44,6 +44,11 @@ export function DialogLocalServer() { const busy = createMemo(() => !!current()?.job) const mode = createMemo(() => current()?.config.mode ?? "windows") const selected = createMemo(() => current()?.checks.distro?.selected) + const otherDistros = createMemo(() => + (current()?.checks.distro?.online ?? []) + .filter((item) => item.name !== "Debian" && item.name !== "Ubuntu-24.04") + .slice(0, 8), + ) const configuredRuntime = createMemo(() => { const state = current() if (!state) return { mode: "windows" as const, distro: null as string | null } @@ -224,6 +229,26 @@ export function DialogLocalServer() { + 0}> +
+
Other distros
+
+ + {(item) => ( + + )} + +
+
+
+
Installed distros
Date: Thu, 16 Apr 2026 15:04:18 +1000 Subject: [PATCH 24/88] fix: treat WSL sidecars as local --- packages/app/src/context/server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 8a617df28185..5c86c9f4bed2 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -221,7 +221,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ) const isLocal = createMemo(() => { const c = current() - return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) + return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) }) return { From 8554345ba088a756826956bdfcbfadf7045bb441 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:12:27 +1000 Subject: [PATCH 25/88] feat: turn local server setup into guided step flow --- .../src/components/dialog-local-server.tsx | 622 ++++++++++++------ .../src/components/dialog-select-server.tsx | 206 +++--- 2 files changed, 524 insertions(+), 304 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 524fbfb06ad9..853f90661b98 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -1,17 +1,20 @@ import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerConfig, LocalServerState } from "@/context/platform" +import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform" import { usePlatform } from "@/context/platform" +const STEP_ORDER: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] + export function DialogLocalServer() { const language = useLanguage() const platform = usePlatform() const [store, setStore] = createStore({ state: undefined as LocalServerState | undefined, loading: true, + step: undefined as LocalServerStep | undefined, }) createEffect(() => { @@ -43,7 +46,10 @@ export function DialogLocalServer() { const localServer = () => platform.localServer const busy = createMemo(() => !!current()?.job) const mode = createMemo(() => current()?.config.mode ?? "windows") - const selected = createMemo(() => current()?.checks.distro?.selected) + const selectedProbe = createMemo(() => current()?.checks.distro?.selected) + const selectedInstalled = createMemo(() => + (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), + ) const otherDistros = createMemo(() => (current()?.checks.distro?.online ?? []) .filter((item) => item.name !== "Debian" && item.name !== "Ubuntu-24.04") @@ -62,6 +68,33 @@ export function DialogLocalServer() { if (!state) return false return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro }) + const wslReady = createMemo(() => !!current()?.checks.wsl?.available && !current()?.config.onboarding.pendingRestart) + const distroReady = createMemo(() => { + const probe = selectedProbe() + if (!probe || !current()?.config.distro) return false + if (selectedInstalled()?.version === 1) return false + return probe.canExecute && probe.hasBash && probe.hasCurl + }) + const opencodeReady = createMemo(() => !!current()?.checks.opencode?.resolvedPath) + const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) + const recommendedStep = createMemo(() => { + if (!wslReady()) return "wsl" + if (!distroReady()) return "distro" + if (!opencodeReady()) return "opencode" + return "switch" + }) + const activeStep = createMemo(() => store.step ?? current()?.job?.step ?? recommendedStep()) + + createEffect(() => { + if (mode() !== "wsl") { + if (store.step) setStore("step", undefined) + return + } + const next = current()?.job?.step ?? recommendedStep() + if (!store.step || stepIndex(store.step) > stepIndex(next)) { + setStore("step", next) + } + }) const run = async (action: () => Promise) => { try { @@ -77,6 +110,7 @@ export function DialogLocalServer() { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) + if (next === "wsl") setStore("step", "wsl") await run(() => localServer()!.setConfig({ ...config, @@ -85,7 +119,7 @@ export function DialogLocalServer() { ...config.onboarding, complete: next === "windows", pendingRestart: next === "windows" ? false : config.onboarding.pendingRestart, - step: next === "windows" ? null : config.onboarding.step, + step: next === "windows" ? null : (config.onboarding.step ?? "wsl"), }, }), ) @@ -95,6 +129,7 @@ export function DialogLocalServer() { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) + setStore("step", "distro") await run(() => localServer()!.setConfig({ ...config, @@ -109,6 +144,33 @@ export function DialogLocalServer() { ) } + const steps = createMemo(() => + STEP_ORDER.map((step) => ({ + step, + title: stepTitle(step), + subtitle: stepSubtitle(step, { + current: current(), + selectedInstalled: selectedInstalled(), + selectedProbe: selectedProbe(), + wslReady: wslReady(), + distroReady: distroReady(), + opencodeReady: opencodeReady(), + switchReady: switchReady(), + needsRestart: needsRestart(), + }), + locked: stepIndex(step) > stepIndex(recommendedStep()), + state: stepState(step, { + active: activeStep(), + current: current(), + wslReady: wslReady(), + distroReady: distroReady(), + opencodeReady: opencodeReady(), + switchReady: switchReady(), + needsRestart: needsRestart(), + }), + })), + ) + return (
Loading local server...
} >
-
Runtime
-
- - +
+
+
Local runtime
+
Choose where the managed Local Server should run.
+
+
+ + +
Current runtime:{" "} - {current()?.runtime.mode === "wsl" - ? `wsl${current()?.runtime.distro ? `:${current()?.runtime.distro}` : ""}` - : "windows"} + {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"}
- -
-
Restart OpenCode to apply local runtime changes.
- + +
+ Select Run in WSL to start the WSL setup flow.
-
-
-
WSL
-
- {current()?.checks.wsl?.error ?? - current()?.checks.wsl?.status ?? - current()?.checks.wsl?.version ?? - "Not checked yet"} -
-
-
- - -
+
Setup flow
+
+ + {(item) => ( + + )} +
- -
- Windows restart required to finish WSL installation. -
-
-
-
-
-
Distro
-
- {current()?.checks.distro?.error ?? - selected()?.name ?? - current()?.config.distro ?? - "No distro selected"} + + +
+
+
+
Step 1: Verify WSL
+
+ {current()?.checks.wsl?.error ?? + current()?.checks.wsl?.status ?? + current()?.checks.wsl?.version ?? + "WSL has not been checked yet."} +
+
+
+ + +
+ +
+
+ Windows restart required to finish WSL installation. +
+ +
+
-
- -
-
+ -
- - -
+ +
+
+
+
Step 2: Choose a distro
+
+ {current()?.checks.distro?.error ?? + current()?.config.distro ?? + "Pick a distro or install one below."} +
+
+ +
- 0}> -
-
Other distros
- - {(item) => ( - - )} - + +
-
-
-
-
Installed distros
- 0} - fallback={
No distros detected yet.
} - > - - {(item) => ( - - )} - -
-
- - - {(probe) => ( -
-
Selected distro checks
-
- User: {probe().username ?? "unknown"} - {probe().isRoot ? " · root" : ""} -
-
- bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} - {probe().canExecute ? "yes" : "no"} + 0}> +
+
Other distros
+
+ + {(item) => ( + + )} + +
+
+ +
+
Installed distros
+ 0} + fallback={
No distros detected yet.
} + > + + {(item) => ( + + )} + +
- )} - -
- -
-
+ + {(probe) => ( +
+
Selected distro checks
+
+ User: {probe().username ?? "unknown"} + {probe().isRoot ? " · root" : ""} + {selectedInstalled()?.version === 1 ? " · WSL 1" : ""} +
+
+ bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} + {probe().canExecute ? "yes" : "no"} +
+ +
+ WSL 2 is required. Convert this distro before continuing. +
+
+
+ )} +
- -
-
-
-
OpenCode
-
- {current()?.checks.opencode?.error ?? current()?.checks.opencode?.resolvedPath ?? "Not checked yet"} -
-
+
+
+ + + +
+
+
+
Step 3: Install OpenCode
+
+ {current()?.checks.opencode?.error ?? + current()?.checks.opencode?.resolvedPath ?? + "OpenCode has not been checked in this distro yet."} +
+
+
+ + +
+
+ + + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` · desktop ${expected()}`}} + +
+ +
+ Installed version does not match the desktop app version. +
+
+
+ )} +
+
+
+ + +
+
+
+
Step 4: Switch Local Server
+
+ {needsRestart() + ? "Restart OpenCode to apply your WSL local runtime configuration." + : "WSL local runtime is configured and active."} +
+
-
- - {(check) => ( -
-
Path: {check().resolvedPath ?? "not found"}
-
- Version: {check().version ?? "unknown"} - - {(expected) => {` · desktop ${expected()}`}} - -
- -
- Installed version does not match the desktop app version. -
-
+
+
+ Configured runtime:{" "} + {configuredRuntime().mode === "wsl" ? `wsl:${configuredRuntime().distro ?? "unknown"}` : "windows"}
- )} - -
- +
+ Current runtime:{" "} + {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} +
+ +
+ Complete the earlier setup steps before switching. +
+
+
+
+ +
0}> @@ -375,3 +524,72 @@ function requestError(language: ReturnType, err: unknown) { description: err instanceof Error ? err.message : String(err), }) } + +function stepIndex(step: LocalServerStep) { + return STEP_ORDER.indexOf(step) +} + +function stepNumber(step: LocalServerStep) { + return `${stepIndex(step) + 1}` +} + +function stepTitle(step: LocalServerStep) { + if (step === "wsl") return "WSL" + if (step === "distro") return "Distro" + if (step === "opencode") return "OpenCode" + return "Switch" +} + +function stepSubtitle( + step: LocalServerStep, + state: { + current?: LocalServerState + selectedInstalled?: LocalServerState["checks"]["distro"] extends infer T ? any : never + selectedProbe?: LocalServerState["checks"]["distro"] extends infer T ? any : never + wslReady: boolean + distroReady: boolean + opencodeReady: boolean + switchReady: boolean + needsRestart: boolean + }, +) { + if (step === "wsl") { + if (state.wslReady) return "Ready" + return state.current?.checks.wsl?.error ?? "Install or verify WSL" + } + if (step === "distro") { + if (state.distroReady) return state.current?.config.distro ?? "Ready" + if (state.selectedInstalled?.version === 1) return "Convert to WSL 2" + return state.current?.checks.distro?.error ?? state.current?.config.distro ?? "Choose a distro" + } + if (step === "opencode") { + if (state.opencodeReady) return state.current?.checks.opencode?.version ?? "Ready" + return state.current?.checks.opencode?.error ?? "Install OpenCode" + } + if (!state.switchReady) return "Complete prior steps" + return state.needsRestart ? "Restart to apply" : "Active" +} + +function stepState( + step: LocalServerStep, + state: { + active: LocalServerStep + current?: LocalServerState + wslReady: boolean + distroReady: boolean + opencodeReady: boolean + switchReady: boolean + needsRestart: boolean + }, +) { + if (state.current?.job?.step === step) return "current" + if (state.active === step) return "current" + if (step === "wsl") return state.wslReady ? "done" : "warning" + if (step === "distro") + return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" + if (step === "opencode") + return state.opencodeReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" + if (state.switchReady && !state.needsRestart) return "done" + if (stepIndex(step) > stepIndex(state.active)) return "locked" + return "warning" +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index caefa2cf94a4..3d375c34a710 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -479,6 +479,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { setStore("localServer", "showPage", true) } + const localSwapLabel = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar") return "" + return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL" + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -558,114 +563,111 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } > -
- -
- -
-
- - x.http.url} - onSelect={(x) => { - if (x) void select(x) - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" - > - {(i) => { - const key = ServerConnection.key(i) - return ( -
-
- -
- - - {language.t("dialog.server.status.default")} - - - } - showCredentials - /> -
- - + x.http.url} + onSelect={(x) => { + if (x) void select(x) + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + - - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} + } + showCredentials + /> +
+ + + + + + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + setDefault(key)}> + + {language.t("dialog.server.menu.default")} + - - setDefault(key)}> - - {language.t("dialog.server.menu.default")} - - - - - setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > + + + setDefault(null)}> - {language.t("dialog.server.menu.delete")} + {language.t("dialog.server.menu.defaultRemove")} - - - - -
+
+ + handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + +
- ) - }} -
-
+
+ ) + }} +
From 1baa87bf0ddc23d543d9a9b399515dfc7678a475 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:22:51 +1000 Subject: [PATCH 26/88] fix: tighten local server wizard dialog flow --- .../src/components/dialog-local-server.tsx | 603 ++++++++---------- .../src/components/dialog-select-server.tsx | 17 +- packages/ui/src/components/dialog.tsx | 7 + packages/ui/src/context/dialog.tsx | 2 +- 4 files changed, 271 insertions(+), 358 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 853f90661b98..5d01d71e1146 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -6,9 +6,9 @@ import { useLanguage } from "@/context/language" import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform" import { usePlatform } from "@/context/platform" -const STEP_ORDER: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] +const WSL_STEPS: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] -export function DialogLocalServer() { +export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const language = useLanguage() const platform = usePlatform() const [store, setStore] = createStore({ @@ -44,8 +44,10 @@ export function DialogLocalServer() { const current = () => store.state const localServer = () => platform.localServer + const targetMode = createMemo<"windows" | "wsl">( + () => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"), + ) const busy = createMemo(() => !!current()?.job) - const mode = createMemo(() => current()?.config.mode ?? "windows") const selectedProbe = createMemo(() => current()?.checks.distro?.selected) const selectedInstalled = createMemo(() => (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), @@ -78,6 +80,7 @@ export function DialogLocalServer() { const opencodeReady = createMemo(() => !!current()?.checks.opencode?.resolvedPath) const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) const recommendedStep = createMemo(() => { + if (targetMode() === "windows") return "switch" if (!wslReady()) return "wsl" if (!distroReady()) return "distro" if (!opencodeReady()) return "opencode" @@ -86,10 +89,6 @@ export function DialogLocalServer() { const activeStep = createMemo(() => store.step ?? current()?.job?.step ?? recommendedStep()) createEffect(() => { - if (mode() !== "wsl") { - if (store.step) setStore("step", undefined) - return - } const next = current()?.job?.step ?? recommendedStep() if (!store.step || stepIndex(store.step) > stepIndex(next)) { setStore("step", next) @@ -106,68 +105,57 @@ export function DialogLocalServer() { const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(unwrap(config)) - const setMode = async (next: "windows" | "wsl") => { + const selectDistro = async (name: string) => { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) - if (next === "wsl") setStore("step", "wsl") + setStore("step", "distro") await run(() => localServer()!.setConfig({ ...config, - mode: next, + mode: "wsl", + distro: name, onboarding: { ...config.onboarding, - complete: next === "windows", - pendingRestart: next === "windows" ? false : config.onboarding.pendingRestart, - step: next === "windows" ? null : (config.onboarding.step ?? "wsl"), + complete: false, + step: "distro", }, }), ) } - const selectDistro = async (name: string) => { + const swapToWindows = async () => { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) - setStore("step", "distro") await run(() => localServer()!.setConfig({ ...config, - mode: "wsl", - distro: name, + mode: "windows", + distro: null, onboarding: { ...config.onboarding, - complete: false, - step: "distro", + complete: true, + pendingRestart: false, + step: null, }, }), ) } const steps = createMemo(() => - STEP_ORDER.map((step) => ({ + WSL_STEPS.filter((step) => targetMode() === "wsl" || step === "switch").map((step) => ({ step, title: stepTitle(step), - subtitle: stepSubtitle(step, { - current: current(), - selectedInstalled: selectedInstalled(), - selectedProbe: selectedProbe(), - wslReady: wslReady(), - distroReady: distroReady(), - opencodeReady: opencodeReady(), - switchReady: switchReady(), - needsRestart: needsRestart(), - }), - locked: stepIndex(step) > stepIndex(recommendedStep()), state: stepState(step, { active: activeStep(), - current: current(), wslReady: wslReady(), distroReady: distroReady(), opencodeReady: opencodeReady(), switchReady: switchReady(), needsRestart: needsRestart(), }), + locked: stepIndex(step) > stepIndex(recommendedStep()), })), ) @@ -177,331 +165,276 @@ export function DialogLocalServer() { when={!store.loading} fallback={
Loading local server...
} > -
-
-
-
Local runtime
-
Choose where the managed Local Server should run.
-
-
- - -
+ +
+ + {(item) => ( + + )} +
-
- Current runtime:{" "} - {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} -
- -
- Select Run in WSL to start the WSL setup flow. -
-
-
- - -
-
Setup flow
-
- - {(item) => ( - - )} - -
-
- - - -
-
-
-
Step 1: Verify WSL
-
- {current()?.checks.wsl?.error ?? - current()?.checks.wsl?.status ?? - current()?.checks.wsl?.version ?? - "WSL has not been checked yet."} -
-
-
- - -
-
- -
-
- Windows restart required to finish WSL installation. -
- -
-
-
-
+
- -
-
-
-
Step 2: Choose a distro
-
- {current()?.checks.distro?.error ?? - current()?.config.distro ?? - "Pick a distro or install one below."} -
-
+ + +
+
+
Verify WSL
+
-
- -
-
+
+
+ {current()?.checks.wsl?.error ?? + current()?.checks.wsl?.status ?? + current()?.checks.wsl?.version ?? + "WSL has not been checked yet."} +
+ +
+
Windows restart required.
+
+
+
+
- 0}> -
-
Other distros
-
- - {(item) => ( - - )} - -
-
-
+ +
+
+
Choose a distro
+ +
+
+ {current()?.checks.distro?.error ?? current()?.config.distro ?? "Pick a distro or install one below."} +
-
-
Installed distros
- 0} - fallback={
No distros detected yet.
} - > - - {(item) => ( - - )} - -
+
+ + +
+ + 0}> +
+ + {(item) => ( + + )} +
+
- - {(probe) => ( -
-
Selected distro checks
-
- User: {probe().username ?? "unknown"} - {probe().isRoot ? " · root" : ""} - {selectedInstalled()?.version === 1 ? " · WSL 1" : ""} -
-
- bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} - {probe().canExecute ? "yes" : "no"} -
- -
- WSL 2 is required. Convert this distro before continuing. +
+ 0} + fallback={
No distros detected yet.
} + > + + {(item) => ( +
- )} + + )} + +
+ + + {(probe) => ( +
+
+ User: {probe().username ?? "unknown"} + {probe().isRoot ? " · root" : ""} + {selectedInstalled()?.version === 1 ? " · WSL 1" : ""} +
+
+ bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} + {probe().canExecute ? "yes" : "no"} +
+ +
WSL 2 is required.
+
+
+ )} +
+ + +
+ -
+ +
+
+
Install OpenCode
+
+
- - - -
-
-
-
Step 3: Install OpenCode
-
- {current()?.checks.opencode?.error ?? - current()?.checks.opencode?.resolvedPath ?? - "OpenCode has not been checked in this distro yet."} -
-
-
- - -
-
- - - {(check) => ( -
-
Path: {check().resolvedPath ?? "not found"}
-
- Version: {check().version ?? "unknown"} - - {(expected) => {` · desktop ${expected()}`}} - -
- -
- Installed version does not match the desktop app version. -
-
-
- )} -
+
+ {current()?.checks.opencode?.error ?? + current()?.checks.opencode?.resolvedPath ?? + "OpenCode has not been checked in this distro yet."}
- - - -
-
-
-
Step 4: Switch Local Server
-
- {needsRestart() - ? "Restart OpenCode to apply your WSL local runtime configuration." - : "WSL local runtime is configured and active."} + + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` · desktop ${expected()}`}} +
+ +
+ Installed version does not match the desktop app version. +
+
+ )} +
+
+ + + +
+
+
+ {targetMode() === "windows" ? "Swap to Windows" : "Switch Local Server"} +
+
+ + +
+
-
-
- Configured runtime:{" "} - {configuredRuntime().mode === "wsl" ? `wsl:${configuredRuntime().distro ?? "unknown"}` : "windows"} -
-
- Current runtime:{" "} - {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} -
- -
- Complete the earlier setup steps before switching. -
-
+
+ {targetMode() === "windows" + ? configuredRuntime().mode === "windows" + ? "Restart OpenCode to finish switching back to Windows." + : "Switch the Local Server target back to Windows." + : needsRestart() + ? "Restart OpenCode to finish switching to WSL." + : "WSL Local Server is active."} +
+ +
+
+ Configured:{" "} + {configuredRuntime().mode === "wsl" ? `wsl:${configuredRuntime().distro ?? "unknown"}` : "windows"}
+
+ Current:{" "} + {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} +
+ +
Complete the earlier steps first.
+
- - - +
+ + 0}>
@@ -526,55 +459,20 @@ function requestError(language: ReturnType, err: unknown) { } function stepIndex(step: LocalServerStep) { - return STEP_ORDER.indexOf(step) -} - -function stepNumber(step: LocalServerStep) { - return `${stepIndex(step) + 1}` + return WSL_STEPS.indexOf(step) } function stepTitle(step: LocalServerStep) { - if (step === "wsl") return "WSL" - if (step === "distro") return "Distro" - if (step === "opencode") return "OpenCode" + if (step === "wsl") return "Verify WSL" + if (step === "distro") return "Choose distro" + if (step === "opencode") return "Install OpenCode" return "Switch" } -function stepSubtitle( - step: LocalServerStep, - state: { - current?: LocalServerState - selectedInstalled?: LocalServerState["checks"]["distro"] extends infer T ? any : never - selectedProbe?: LocalServerState["checks"]["distro"] extends infer T ? any : never - wslReady: boolean - distroReady: boolean - opencodeReady: boolean - switchReady: boolean - needsRestart: boolean - }, -) { - if (step === "wsl") { - if (state.wslReady) return "Ready" - return state.current?.checks.wsl?.error ?? "Install or verify WSL" - } - if (step === "distro") { - if (state.distroReady) return state.current?.config.distro ?? "Ready" - if (state.selectedInstalled?.version === 1) return "Convert to WSL 2" - return state.current?.checks.distro?.error ?? state.current?.config.distro ?? "Choose a distro" - } - if (step === "opencode") { - if (state.opencodeReady) return state.current?.checks.opencode?.version ?? "Ready" - return state.current?.checks.opencode?.error ?? "Install OpenCode" - } - if (!state.switchReady) return "Complete prior steps" - return state.needsRestart ? "Restart to apply" : "Active" -} - function stepState( step: LocalServerStep, state: { active: LocalServerStep - current?: LocalServerState wslReady: boolean distroReady: boolean opencodeReady: boolean @@ -582,7 +480,6 @@ function stepState( needsRestart: boolean }, ) { - if (state.current?.job?.step === step) return "current" if (state.active === step) return "current" if (step === "wsl") return state.wslReady ? "done" : "warning" if (step === "distro") diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 3d375c34a710..45e67d88dc4e 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -22,6 +22,7 @@ const DEFAULT_USERNAME = "opencode" interface DialogSelectServerProps { initialView?: "list" | "local" + initialTargetMode?: "windows" | "wsl" } interface ServerFormProps { @@ -198,6 +199,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, localServer: { showPage: props.initialView === "local", + targetMode: props.initialTargetMode as "windows" | "wsl" | undefined, }, editServer: { id: undefined as string | undefined, @@ -443,6 +445,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { resetAdd() resetEdit() setStore("localServer", "showPage", false) + setStore("localServer", "targetMode", undefined) } const startAdd = () => { @@ -473,10 +476,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) } - const startLocal = () => { + const startLocal = (targetMode?: "windows" | "wsl") => { resetAdd() resetEdit() setStore("localServer", "showPage", true) + setStore("localServer", "targetMode", targetMode) } const localSwapLabel = (conn: ServerConnection.Any) => { @@ -484,6 +488,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL" } + const localSwapTarget = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar") return undefined + return conn.variant === "wsl" ? "windows" : "wsl" + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -533,7 +542,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } return ( - +
} > - + } > @@ -607,7 +616,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { class="shrink-0" onClick={(event: MouseEvent) => { event.stopPropagation() - startLocal() + startLocal(localSwapTarget(i)) }} > {localSwapLabel(i)} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 981e3f45d74f..88f43178ca1f 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -12,6 +12,7 @@ export interface DialogProps extends ParentProps { classList?: ComponentProps<"div">["classList"] fit?: boolean transition?: boolean + dismissOutside?: boolean } export function Dialog(props: DialogProps) { @@ -31,6 +32,12 @@ export function Dialog(props: DialogProps) { ...props.classList, [props.class ?? ""]: !!props.class, }} + onInteractOutside={(e) => { + if (props.dismissOutside === false) e.preventDefault() + }} + onPointerDownOutside={(e) => { + if (props.dismissOutside === false) e.preventDefault() + }} onOpenAutoFocus={(e) => { const target = e.currentTarget as HTMLElement | null const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index c1c56212b5f6..b4d866768b6c 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -105,7 +105,7 @@ function init() { }} > - + {element()} From 76da54c596e00d67cbc81e4892a1e41bf17c49b3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:31:54 +1000 Subject: [PATCH 27/88] fix: harden local server WSL onboarding checks --- .../src/components/dialog-local-server.tsx | 406 ++++++++++++------ packages/app/src/context/platform.tsx | 1 + .../desktop-electron/src/main/local-server.ts | 49 ++- packages/desktop-electron/src/main/wsl.ts | 87 +++- .../desktop-electron/src/preload/types.ts | 1 + packages/ui/src/components/dialog.css | 5 +- 6 files changed, 389 insertions(+), 160 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 5d01d71e1146..8739a27d0444 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -1,6 +1,7 @@ import { Button } from "@opencode-ai/ui/button" +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 { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform" @@ -15,6 +16,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { state: undefined as LocalServerState | undefined, loading: true, step: undefined as LocalServerStep | undefined, + installTarget: undefined as string | undefined, }) createEffect(() => { @@ -47,16 +49,54 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const targetMode = createMemo<"windows" | "wsl">( () => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"), ) + const configuredDistro = createMemo(() => current()?.config.distro ?? null) const busy = createMemo(() => !!current()?.job) - const selectedProbe = createMemo(() => current()?.checks.distro?.selected) + const selectedProbe = createMemo(() => { + const probe = current()?.checks.distro?.selected + return probe?.name === configuredDistro() ? probe : null + }) const selectedInstalled = createMemo(() => (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), ) - const otherDistros = createMemo(() => - (current()?.checks.distro?.online ?? []) - .filter((item) => item.name !== "Debian" && item.name !== "Ubuntu-24.04") - .slice(0, 8), + const defaultInstalledDistro = createMemo( + () => (current()?.checks.distro?.installed ?? []).find((item) => item.isDefault) ?? null, ) + const opencodeCheck = createMemo(() => { + const check = current()?.checks.opencode + return check?.distro === configuredDistro() ? check : null + }) + const distroWarningProbe = createMemo(() => { + const probe = selectedProbe() + if (!probe) return null + if (distroReady() && !probe.isRoot) return null + return probe + }) + const distroUnavailableMessage = createMemo(() => { + const probe = distroWarningProbe() + const distro = configuredDistro() + if (!probe || probe.canExecute || !distro) return null + if (!selectedInstalled()) return `${distro} is not installed yet.` + return `Open ${distro} once to finish setup.` + }) + const distroMissingTools = createMemo(() => { + const probe = distroWarningProbe() + if (!probe?.canExecute) return null + if (probe.hasBash && probe.hasCurl) return null + return probe + }) + const opencodeMismatchCheck = createMemo(() => { + const check = opencodeCheck() + return check?.matchesDesktop === false ? check : null + }) + const installableDistros = createMemo(() => { + const online = current()?.checks.distro?.online ?? [] + const installed = new Set((current()?.checks.distro?.installed ?? []).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) ?? null) const configuredRuntime = createMemo(() => { const state = current() if (!state) return { mode: "windows" as const, distro: null as string | null } @@ -77,7 +117,10 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { if (selectedInstalled()?.version === 1) return false return probe.canExecute && probe.hasBash && probe.hasCurl }) - const opencodeReady = createMemo(() => !!current()?.checks.opencode?.resolvedPath) + const opencodeReady = createMemo(() => { + const check = opencodeCheck() + return !!check?.resolvedPath && !check.error && check.matchesDesktop !== false + }) const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) const recommendedStep = createMemo(() => { if (targetMode() === "windows") return "switch" @@ -86,12 +129,102 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { if (!opencodeReady()) return "opencode" return "switch" }) - const activeStep = createMemo(() => store.step ?? current()?.job?.step ?? recommendedStep()) + const activeStep = createMemo(() => current()?.job?.step ?? store.step ?? recommendedStep()) - createEffect(() => { - const next = current()?.job?.step ?? recommendedStep() - if (!store.step || stepIndex(store.step) > stepIndex(next)) { + createEffect( + on(recommendedStep, (next) => { setStore("step", next) + }), + ) + + const autoProbe = createMemo(() => { + const state = current() + if (!state || !localServer() || busy() || targetMode() === "windows") return null + if (state.config.onboarding.pendingRestart) return null + if (!state.checks.wsl) return { key: "wsl", step: "wsl" as const } + if (!wslReady()) return null + if (!state.checks.distro) return { key: "distro:list", step: "distro" as const } + if (state.config.distro && !selectedProbe()) { + return { key: `distro:${state.config.distro}`, step: "distro" as const } + } + if (!state.config.distro || !distroReady()) return null + if (!opencodeCheck()) { + return { key: `opencode:${state.config.distro}`, step: "opencode" as const } + } + return null + }) + + let lastAutoProbe: string | null = null + createEffect(() => { + const probe = autoProbe() + if (!probe || probe.key === lastAutoProbe) return + lastAutoProbe = probe.key + void run(() => localServer()!.runStep(probe.step)) + }) + + createEffect(() => { + const state = current() + const distro = defaultInstalledDistro() + if (!state || !distro || !localServer() || busy() || targetMode() !== "wsl") return + if (state.config.distro) return + void selectDistro(distro.name) + }) + + createEffect(() => { + const distros = installableDistros() + if (!distros.length) { + if (store.installTarget) setStore("installTarget", undefined) + return + } + if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return + setStore("installTarget", distros[0]!.name) + }) + + const wslMessage = createMemo(() => { + const state = current() + if (!state || state.job?.step === "wsl") return "Checking WSL..." + if (state.config.onboarding.pendingRestart) return "Windows needs a restart to finish installing WSL." + if (state.checks.wsl?.available) return state.checks.wsl.version ?? "WSL is ready." + return state.checks.wsl?.error ?? "WSL is required to continue." + }) + + const distroMessage = createMemo(() => { + const state = current() + if (!state) return "Checking distros..." + if (state.job?.step === "distro") { + if (state.config.distro && !selectedInstalled()) return `Installing ${state.config.distro}...` + return state.config.distro ? `Checking ${state.config.distro}...` : "Checking distros..." + } + if (distroUnavailableMessage()) return distroUnavailableMessage()! + if (state.checks.distro?.error && !selectedProbe()) return state.checks.distro.error + if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.` + if (state.config.distro) return `Finishing setup for ${state.config.distro}.` + return "Pick a distro or install one below." + }) + + const opencodeMessage = createMemo(() => { + const state = current() + if (!state) return "Checking OpenCode..." + if (state.job?.step === "opencode") { + return state.config.distro ? `Checking OpenCode in ${state.config.distro}...` : "Checking OpenCode..." + } + if (opencodeCheck()?.error) return opencodeCheck()!.error + if (opencodeCheck()?.matchesDesktop === false) { + return state.config.distro ? `Update OpenCode in ${state.config.distro}.` : "Update OpenCode." + } + if (opencodeReady()) + return state.config.distro ? `OpenCode is ready in ${state.config.distro}.` : "OpenCode is ready." + return state.config.distro ? `Install OpenCode in ${state.config.distro}.` : "Choose a distro first." + }) + const installProgress = createMemo(() => { + const state = current() + if (!state?.job || state.status.kind !== "running") return null + const transcript = state.transcript.filter((line) => line.text.trim()) + const title = transcript[0]?.text + if (!title?.startsWith("Installing ")) return null + return { + title, + lines: transcript.slice(1).slice(-8), } }) @@ -192,16 +325,8 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
-
Verify WSL
-
- +
WSL
+ -
-
-
- {current()?.checks.wsl?.error ?? - current()?.checks.wsl?.status ?? - current()?.checks.wsl?.version ?? - "WSL has not been checked yet."} +
+
{wslMessage()}
Windows restart required.
@@ -231,61 +351,17 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
-
-
Choose a distro
- -
-
- {current()?.checks.distro?.error ?? current()?.config.distro ?? "Pick a distro or install one below."} -
- -
- - -
- - 0}> -
- - {(item) => ( - - )} - -
-
+
Choose a distro
+
{distroMessage()}
0} - fallback={
No distros detected yet.
} + fallback={ +
+ {current()?.checks.distro ? "No distros detected yet." : "Checking distros..."} +
+ } > {(item) => ( @@ -307,29 +383,90 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
- - {(probe) => ( -
-
- User: {probe().username ?? "unknown"} - {probe().isRoot ? " · root" : ""} - {selectedInstalled()?.version === 1 ? " · WSL 1" : ""} -
-
- bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} - {probe().canExecute ? "yes" : "no"} -
- -
WSL 2 is required.
-
+ 0}> +
+
+
Install
+
- )} +
+ + {(item) => { + const selected = () => store.installTarget === item.name + return ( + + ) + }} + +
+
+
+ + +
+ +
WSL 2 is required.
+
+ + {(message) =>
{message()}
} +
+ +
This distro needs bash and curl.
+
+ +
+ This distro is using the root user right now. +
+
+
+
OpenCode
+ -
-
-
- {current()?.checks.opencode?.error ?? - current()?.checks.opencode?.resolvedPath ?? - "OpenCode has not been checked in this distro yet."} +
- +
{opencodeMessage()}
+ {(check) => (
Path: {check().resolvedPath ?? "not found"}
@@ -375,11 +500,9 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { {(expected) => {` · desktop ${expected()}`}}
- -
- Installed version does not match the desktop app version. -
-
+
+ Installed version does not match the desktop app version. +
)}
@@ -389,9 +512,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
-
- {targetMode() === "windows" ? "Swap to Windows" : "Switch Local Server"} -
+
Switch
+
+
+ ) +} + const createPlatform = (): Platform => { const os = (() => { const ua = navigator.userAgent @@ -275,8 +308,19 @@ render(() => { const [windowCount] = createResource(() => window.api.getWindowCount()) - // Fetch sidecar credentials (available immediately, before health check) - const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) + const [startup] = createResource(async () => { + try { + return { + error: null, + sidecar: await window.api.awaitInitialization(() => undefined), + } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + sidecar: null, + } + } + }) const [defaultServer] = createResource(() => platform.getDefaultServer?.().then((url) => { @@ -286,7 +330,7 @@ render(() => { const [locale] = createResource(loadLocale) const servers = () => { - const data = sidecar() + const data = startup.latest?.sidecar if (!data) return [] const server: ServerConnection.Sidecar = { displayName: "Local Server", @@ -339,12 +383,16 @@ render(() => { return ( - + {(_) => { + if (startup.latest?.error) { + return + } return ( Date: Thu, 16 Apr 2026 16:32:16 +1000 Subject: [PATCH 29/88] fix: remove route dependency from server dialog --- packages/app/src/components/dialog-select-server.tsx | 7 +++---- packages/app/src/components/status-popover-body.tsx | 5 ++++- packages/app/src/pages/home.tsx | 2 +- packages/app/src/pages/layout.tsx | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 45e67d88dc4e..c229c37189ea 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,6 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogLocalServer } from "@/components/dialog-local-server" @@ -23,6 +22,7 @@ const DEFAULT_USERNAME = "opencode" interface DialogSelectServerProps { initialView?: "list" | "local" initialTargetMode?: "windows" | "wsl" + onNavigateHome?: () => void } interface ServerFormProps { @@ -178,7 +178,6 @@ function ServerForm(props: ServerFormProps) { } export function DialogSelectServer(props: DialogSelectServerProps = {}) { - const navigate = useNavigate() const dialog = useDialog() const server = useServer() const platform = usePlatform() @@ -364,10 +363,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { dialog.close() if (persist && conn.type === "http") { server.add(conn) - navigate("/") + props.onNavigateHome?.() return } - navigate("/") + props.onNavigateHome?.() queueMicrotask(() => server.setActive(ServerConnection.key(conn))) } diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0f6a1c1355f0..e94c9f112e8e 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -329,7 +329,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const run = ++dialogRun void import("./dialog-select-server").then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => , defaultServer.refresh) + dialog.show( + () => navigate("/")} />, + defaultServer.refresh, + ) }) }} > diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 46cacdf627ab..b779ebd4f54e 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -75,7 +75,7 @@ export default function Home() { size="large" variant="ghost" class="mt-4 mx-auto text-14-regular text-text-weak" - onClick={() => dialog.show(() => )} + onClick={() => dialog.show(() => navigate("/")} />)} >
{ if (dialogDead || dialogRun !== run) return - dialog.show(() => ) + dialog.show(() => navigate("/")} />) }) } From 15092be2040599acd31b1d2a48846176697a1a2d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:10:24 +1000 Subject: [PATCH 30/88] fix: catch desktop local startup errors --- packages/desktop-electron/src/main/index.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index aa2eee7b6f49..5d09a8f64bd9 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -36,13 +36,14 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, UPDATER_ENABLED } from "./constants" +import { CHANNEL, LOCAL_SERVER_KEY, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server" +import { store } from "./store" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" const initEmitter = new EventEmitter() @@ -63,6 +64,12 @@ logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged, }) +logger.log("config paths", { + userData: app.getPath("userData"), + settingsStore: store.path, + localServerKey: LOCAL_SERVER_KEY, + localServer: store.get(LOCAL_SERVER_KEY) ?? null, +}) setupApp() @@ -176,12 +183,12 @@ async function initialize() { try { if (runtime.mode === "wsl") { if (!runtime.distro) throw new Error("No WSL distro selected") - return spawnWslLocalServer(runtime.distro, port, password, { + return await spawnWslLocalServer(runtime.distro, port, password, { onLine: (line) => logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }), }) } - return spawnLocalServer(hostname, port, password) + return await spawnLocalServer(hostname, port, password) } catch (error) { startupError = asError(error) localServer.setStatus({ From 0dae445f4ff5ea6c1d9f28236492e61f9ca833d0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:17:03 +1000 Subject: [PATCH 31/88] fix: detect opencode in existing WSL distros --- packages/desktop-electron/src/main/wsl.ts | 32 ++++++++++------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 98b26dc51380..c11a2bef9d77 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -244,24 +244,20 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { - const result = await runWslBash( - [ - 'path="$(command -v opencode 2>/dev/null || true)"', - 'for candidate in "$path" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" "$HOME/bin/opencode" "$HOME/.opencode/bin/opencode" "/usr/local/bin/opencode"; do', - ' [ -n "$candidate" ] || continue', - ' case "$candidate" in', - " /mnt/*) continue ;;", - " esac", - ' if [ -x "$candidate" ]; then', - ' printf "%s\\n" "$candidate"', - " exit 0", - " fi", - "done", - ].join("\n"), - distro, - opts, - ) - return firstLine(result.stdout) + const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) + if (command && !command.startsWith("/mnt/")) return command + + for (const candidate of [ + 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', + 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', + 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', + 'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi', + ]) { + const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout) + if (resolved) return resolved + } + + return null } export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { From fcc9cab760f75d3f2c337013029030b19215e0bb Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:53:01 +1000 Subject: [PATCH 32/88] fix: separate sidebar drag ids by type --- packages/app/src/pages/layout.tsx | 33 +++++++++++++------ .../app/src/pages/layout/sidebar-project.tsx | 13 +++++++- .../app/src/pages/layout/sidebar-shell.tsx | 3 +- .../src/pages/layout/sidebar-workspace.tsx | 13 +++++++- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c9b980cf79cf..f25cadf713e9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -83,9 +83,16 @@ import { LocalWorkspace, SortableWorkspace, WorkspaceDragOverlay, + workspaceSortableDirectory, + workspaceSortableId, type WorkspaceSidebarContext, } from "./layout/sidebar-workspace" -import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" +import { + ProjectDragOverlay, + SortableProject, + projectSortableWorktree, + type ProjectSidebarContext, +} from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" export default function Layout(props: ParentProps) { @@ -1841,7 +1848,7 @@ export default function Layout(props: ParentProps) { ) function handleDragStart(event: unknown) { - const id = getDraggableId(event) + const id = projectSortableWorktree(getDraggableId(event)) if (!id) return setHoverProject(undefined) setStore("activeProject", id) @@ -1850,11 +1857,14 @@ export default function Layout(props: ParentProps) { function handleDragOver(event: DragEvent) { const { draggable, droppable } = event if (draggable && droppable) { + const from = projectSortableWorktree(draggable.id?.toString()) + const to = projectSortableWorktree(droppable.id?.toString()) + if (!from || !to) return const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) - const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + const fromIndex = projects.findIndex((p) => p.worktree === from) + const toIndex = projects.findIndex((p) => p.worktree === to) if (fromIndex !== toIndex && toIndex !== -1) { - layout.projects.move(draggable.id.toString(), toIndex) + layout.projects.move(from, toIndex) } } } @@ -1892,7 +1902,7 @@ export default function Layout(props: ParentProps) { }) function handleWorkspaceDragStart(event: unknown) { - const id = getDraggableId(event) + const id = workspaceSortableDirectory(getDraggableId(event)) if (!id) return setStore("activeWorkspace", id) } @@ -1900,13 +1910,16 @@ export default function Layout(props: ParentProps) { function handleWorkspaceDragOver(event: DragEvent) { const { draggable, droppable } = event if (!draggable || !droppable) return + const from = workspaceSortableDirectory(draggable.id?.toString()) + const to = workspaceSortableDirectory(droppable.id?.toString()) + if (!from || !to) return const project = sidebarProject() if (!project) return const ids = workspaceIds(project) - const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) - const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + const fromIndex = ids.findIndex((dir) => dir === from) + const toIndex = ids.findIndex((dir) => dir === to) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return @@ -2267,13 +2280,13 @@ export default function Layout(props: ParentProps) { }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" > - + {(directory) => ( diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 076e1ef88b54..d681cf32181b 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -34,6 +34,17 @@ export type ProjectSidebarContext = { sessionProps: Omit } +const PROJECT_SORTABLE_PREFIX = "project:" + +export function projectSortableId(worktree: string) { + return `${PROJECT_SORTABLE_PREFIX}${worktree}` +} + +export function projectSortableWorktree(id: string | undefined) { + if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return + return id.slice(PROJECT_SORTABLE_PREFIX.length) +} + export const ProjectDragOverlay = (props: { projects: Accessor activeProject: Accessor @@ -275,7 +286,7 @@ export const SortableProject = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(props.project.worktree) + const sortable = createSortable(projectSortableId(props.project.worktree)) const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index ca36af2a421c..f8f8ea516531 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -11,6 +11,7 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { type LocalProject } from "@/context/layout" +import { projectSortableId } from "./sidebar-project" export const SidebarContent = (props: { mobile?: boolean @@ -63,7 +64,7 @@ export const SidebarContent = (props: {
- p.worktree)}> + projectSortableId(p.worktree))}> {(project) => props.renderProject(project)} void } +const WORKSPACE_SORTABLE_PREFIX = "workspace:" + +export function workspaceSortableId(directory: string) { + return `${WORKSPACE_SORTABLE_PREFIX}${directory}` +} + +export function workspaceSortableDirectory(id: string | undefined) { + if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return + return id.slice(WORKSPACE_SORTABLE_PREFIX.length) +} + export const WorkspaceDragOverlay = (props: { sidebarProject: Accessor activeWorkspace: Accessor @@ -300,7 +311,7 @@ export const SortableWorkspace = (props: { const params = useParams() const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(props.directory) + const sortable = createSortable(workspaceSortableId(props.directory)) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) const [menu, setMenu] = createStore({ open: false, From 06b27db78ec447236247943ccc8a1a9f3063309f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:58:00 +1000 Subject: [PATCH 33/88] fix: keep sidebar projects stable across WSL refreshes --- packages/app/src/pages/layout.tsx | 15 ++++++++++++--- packages/app/src/pages/layout/sidebar-shell.tsx | 7 ++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f25cadf713e9..cde86f2c3a2a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2343,6 +2343,7 @@ export default function Layout(props: ParentProps) { } const projects = () => layout.projects.list() + const projectIds = createMemo(() => projects().map((project) => project.worktree)) const projectOverlay = () => store.activeProject} /> const sidebarContent = (mobile?: boolean) => ( layout.sidebar.opened()} aimMove={aim.move} projects={projects} - renderProject={(project) => ( - - )} + projectIds={projectIds} + renderProject={(worktree) => { + const project = createMemo(() => projects().find((item) => item.worktree === worktree)) + return ( + + {(project) => ( + + )} + + ) + }} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragOver={handleDragOver} diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index f8f8ea516531..d9cd4d5a205b 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -18,7 +18,8 @@ export const SidebarContent = (props: { opened: Accessor aimMove: (event: MouseEvent) => void projects: Accessor - renderProject: (project: LocalProject) => JSX.Element + projectIds: Accessor + renderProject: (worktree: string) => JSX.Element handleDragStart: (event: unknown) => void handleDragEnd: () => void handleDragOver: (event: DragEvent) => void @@ -64,8 +65,8 @@ export const SidebarContent = (props: {
- projectSortableId(p.worktree))}> - {(project) => props.renderProject(project)} + + {(worktree) => props.renderProject(worktree)} Date: Thu, 16 Apr 2026 18:09:01 +1000 Subject: [PATCH 34/88] fix: keep session header titlebar mounts in sync --- .../src/components/session/session-header.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 021e5be67e35..cf57f4408d91 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/shared/util/path" -import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -129,6 +129,13 @@ const showRequestError = (language: ReturnType, err: unknown }) } +function titlebarMounts() { + return { + center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined, + right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined, + } +} + export function SessionHeader() { const layout = useLayout() const command = useCommand() @@ -219,6 +226,7 @@ export function SessionHeader() { const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, }) + const [mounts, setMounts] = createStore(titlebarMounts()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo( @@ -232,6 +240,19 @@ export function SessionHeader() { messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) + const syncMounts = () => { + const next = titlebarMounts() + if (mounts.center === next.center && mounts.right === next.right) return + setMounts(next) + } + + onMount(() => { + syncMounts() + const observer = new MutationObserver(() => syncMounts()) + observer.observe(document.body, { childList: true, subtree: true }) + onCleanup(() => observer.disconnect()) + }) + const selectApp = (app: OpenApp) => { if (!options().some((item) => item.id === app)) return setPrefs("app", app) @@ -269,12 +290,8 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const [centerMount, setCenterMount] = createSignal(null) - const [rightMount, setRightMount] = createSignal(null) - onMount(() => { - setCenterMount(document.getElementById("opencode-titlebar-center")) - setRightMount(document.getElementById("opencode-titlebar-right")) - }) + const centerMount = createMemo(() => mounts.center) + const rightMount = createMemo(() => mounts.right) return ( <> From 9fa3a99480e38b9a7632803959fefb2a9b66608f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:18:33 +1000 Subject: [PATCH 35/88] fix: make WSL opencode mismatch non-blocking --- packages/app/src/components/dialog-local-server.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 8739a27d0444..f27437466aaa 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -119,7 +119,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { }) const opencodeReady = createMemo(() => { const check = opencodeCheck() - return !!check?.resolvedPath && !check.error && check.matchesDesktop !== false + return !!check?.resolvedPath && !check.error }) const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) const recommendedStep = createMemo(() => { @@ -285,6 +285,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { wslReady: wslReady(), distroReady: distroReady(), opencodeReady: opencodeReady(), + opencodeMismatch: opencodeCheck()?.matchesDesktop === false, switchReady: switchReady(), needsRestart: needsRestart(), }), @@ -632,6 +633,7 @@ function stepState( wslReady: boolean distroReady: boolean opencodeReady: boolean + opencodeMismatch: boolean switchReady: boolean needsRestart: boolean }, @@ -641,7 +643,13 @@ function stepState( if (step === "distro") return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" if (step === "opencode") - return state.opencodeReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" + return state.opencodeMismatch + ? "warning" + : state.opencodeReady + ? "done" + : stepIndex(step) > stepIndex(state.active) + ? "locked" + : "warning" if (state.switchReady && !state.needsRestart) return "done" if (stepIndex(step) > stepIndex(state.active)) return "locked" return "warning" From 17bd7ffbf1cfe6428e19bb69ebe32098aee99d01 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:18:47 +1000 Subject: [PATCH 36/88] refactor: confirm windows local server swap inline --- .../src/components/dialog-select-server.tsx | 130 ++++++++++++++++-- 1 file changed, 118 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index c229c37189ea..71b53bee1f8a 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -5,6 +5,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" +import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" @@ -13,7 +14,7 @@ import { createStore, reconcile } from "solid-js/store" import { DialogLocalServer } from "@/components/dialog-local-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" +import { type LocalServerConfig, usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" @@ -199,6 +200,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { localServer: { showPage: props.initialView === "local", targetMode: props.initialTargetMode as "windows" | "wsl" | undefined, + confirmSwapKey: undefined as ServerConnection.Key | undefined, }, editServer: { id: undefined as string | undefined, @@ -480,6 +482,50 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { resetEdit() setStore("localServer", "showPage", true) setStore("localServer", "targetMode", targetMode) + setStore("localServer", "confirmSwapKey", undefined) + } + + const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(config) + + const swapLocalToWindows = async () => { + const localServer = platform.localServer + if (!localServer) return + + try { + const state = await localServer.getState() + const config = plainConfig(state.config) + await localServer.setConfig({ + ...config, + mode: "windows", + distro: null, + onboarding: { + ...config.onboarding, + complete: true, + pendingRestart: false, + step: null, + }, + }) + setStore("localServer", "confirmSwapKey", undefined) + showToast({ + variant: "success", + title: "Local Server set to Windows", + description: "Restart OpenCode to finish switching back to Windows.", + persistent: true, + actions: [ + { + label: "Restart", + onClick: () => void platform.restart(), + }, + ], + }) + } catch (err) { + console.error("Local Server 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), + }) + } } const localSwapLabel = (conn: ServerConnection.Any) => { @@ -609,17 +655,77 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { />
- + {(() => { + const sidecar = i as ServerConnection.Sidecar + if (sidecar.variant !== "wsl") { + return ( + + ) + } + + return ( + + setStore( + "localServer", + "confirmSwapKey", + open ? ServerConnection.key(sidecar) : undefined, + ) + } + triggerAs={Button} + triggerProps={{ + variant: "secondary", + size: "small", + class: "shrink-0", + onClick: (event: MouseEvent) => event.stopPropagation(), + onPointerDown: (event: PointerEvent) => event.stopPropagation(), + }} + trigger={localSwapLabel(sidecar)} + placement="bottom-end" + portal={false} + > +
+
Use Windows instead?
+
+ Restart OpenCode after switching the Local Server back to Windows. +
+
+ + +
+
+
+ ) + })()}
From d7111a707222183994e632d7e2e00c22326e8e1e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:33:13 +1000 Subject: [PATCH 37/88] fix: prefer nerd fonts in desktop terminal --- packages/app/src/components/terminal.tsx | 6 +++--- packages/app/src/context/settings.tsx | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 57e91d6d3350..bf87e67c2aef 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" -import { monoFontFamily, useSettings } from "@/context/settings" +import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => { }) createEffect(() => { - const font = monoFontFamily(settings.appearance.font()) + const font = terminalFontFamily(settings.appearance.font()) if (!term) return setOptionIfSupported(term, "fontFamily", font) scheduleFit() @@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => { cols: restoreSize?.cols, rows: restoreSize?.rows, fontSize: 14, - fontFamily: monoFontFamily(settings.appearance.font()), + fontFamily: terminalFontFamily(settings.appearance.font()), allowTransparency: false, convertEol: false, theme: terminalColors(), diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index a585789ce464..1534b173eb6c 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -53,9 +53,13 @@ export const sansDefault = "System Sans" const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' +const terminalMonoFallback = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono NFM", "JetBrainsMono NF", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono", "Hack Nerd Font", "MesloLGM Nerd Font Mono", "MesloLGM Nerd Font", "CaskaydiaCove NFM", "CaskaydiaCove Nerd Font Mono", "CaskaydiaMono Nerd Font Mono", ' + + monoFallback const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' const monoBase = monoFallback +const terminalMonoBase = terminalMonoFallback const sansBase = sansFallback function input(font: string | undefined) { @@ -85,6 +89,10 @@ export function monoFontFamily(font: string | undefined) { return stack(font, monoBase) } +export function terminalFontFamily(font: string | undefined) { + return stack(font, terminalMonoBase) +} + export function sansFontFamily(font: string | undefined) { return stack(font, sansBase) } From d8b1d86092ccc2e1a156625ee62ae989976e304d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:29:24 +1000 Subject: [PATCH 38/88] Update dialog-local-server.tsx --- .../src/components/dialog-local-server.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index f27437466aaa..9c8c9b0a65bc 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -4,7 +4,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform" +import type { LocalServerConfig, LocalServerMode, LocalServerState, LocalServerStep } from "@/context/platform" import { usePlatform } from "@/context/platform" const WSL_STEPS: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] @@ -105,6 +105,10 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { } return { mode: "windows" as const, distro: null as string | null } }) + const configuredRuntimeLabel = createMemo(() => runtimeLabel(configuredRuntime().mode, configuredRuntime().distro)) + const currentRuntimeLabel = createMemo(() => + runtimeLabel(current()?.runtime.mode ?? "windows", current()?.runtime.distro ?? null), + ) const needsRestart = createMemo(() => { const state = current() if (!state) return false @@ -533,22 +537,20 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
{targetMode() === "windows" - ? configuredRuntime().mode === "windows" - ? "Restart OpenCode to finish switching back to Windows." - : "Switch the Local Server target back to Windows." + ? needsRestart() + ? "Restart OpenCode to switch back to Windows." + : "Windows Local Server is active." : needsRestart() - ? "Restart OpenCode to finish switching to WSL." - : "WSL Local Server is active."} + ? `Restart OpenCode to start using ${configuredRuntimeLabel()}.` + : `${configuredRuntimeLabel()} is active.`}
- Configured:{" "} - {configuredRuntime().mode === "wsl" ? `wsl:${configuredRuntime().distro ?? "unknown"}` : "windows"} + After restart: {configuredRuntimeLabel()}
- Current:{" "} - {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} + Using now: {currentRuntimeLabel()}
Complete the earlier steps first.
@@ -626,6 +628,11 @@ function stepTitle(step: LocalServerStep) { return "Switch" } +function runtimeLabel(mode: LocalServerMode, distro: string | null) { + if (mode === "windows") return "Windows" + return distro ? `WSL on ${distro}` : "WSL" +} + function stepState( step: LocalServerStep, state: { From 3b1970a0f432b1c96f97c5527cda9f2501f6881a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:39:34 +1000 Subject: [PATCH 39/88] docs: reframe WSL work as additive servers beside windows local --- plan.md | 448 +++++++++++++++++--------------------------------------- 1 file changed, 133 insertions(+), 315 deletions(-) diff --git a/plan.md b/plan.md index 45d3eabc39d7..4862eed89afc 100644 --- a/plan.md +++ b/plan.md @@ -1,320 +1,138 @@ -# WSL Local Server Implementation Backlog +# WSL Server Implementation Backlog This backlog assumes Electron only. It is ordered chronologically. Each task is intended to be small enough for a new engineer to pick up directly. -## 01 Foundation - -- [ ] Add a new persisted `localServer` settings key in `packages/desktop-electron/src/main/constants.ts`. -- [ ] Define a `LocalServerMode` type with `"windows" | "wsl"` in the Electron preload types. -- [ ] Define a persisted `LocalServerConfig` shape in the Electron preload types. -- [ ] Include `mode`, `distro`, onboarding metadata, root acknowledgements, and mismatch acknowledgements in `LocalServerConfig`. -- [ ] Remove the old `WslConfig` type from `packages/desktop-electron/src/preload/types.ts`. -- [ ] Remove the old `WSL_ENABLED_KEY` constant and stop adding new code that depends on it. -- [ ] Add a single source of truth helper in Electron main for reading `LocalServerConfig` from `electron-store`. -- [ ] Add a single source of truth helper in Electron main for writing `LocalServerConfig` to `electron-store`. -- [ ] Ignore any legacy `wslEnabled` value during reads of the new Local Server config. -- [ ] Add explicit comments in the new config helpers that this feature is Electron-only and replaces the legacy WSL boolean. - -## 02 Main-Process Local Server Controller - -- [ ] Create a dedicated Electron main module for Local Server orchestration, for example `packages/desktop-electron/src/main/local-server.ts`. -- [ ] Move local runtime orchestration out of ad hoc startup code and into the Local Server controller. -- [ ] Define an in-memory `LocalServerState` shape for runtime status, current job, current transcript, and startup failure. -- [ ] Keep `LocalServerState` in memory only; do not persist runtime status or raw logs. -- [ ] Add a typed event emitter inside the Local Server controller. -- [ ] Support one active Local Server job at a time in the controller. -- [ ] Add a helper to reject or cancel a previous Local Server job before starting a new one. -- [ ] Allocate the loopback port once per app launch and keep it stable for all Local Server restarts in that launch. -- [ ] Allocate the local auth password once per app launch and keep it stable for all Local Server restarts in that launch. -- [ ] Expose a controller method to return a full current Local Server snapshot. -- [ ] Expose a controller method to subscribe to Local Server events. -- [ ] Expose a controller method to update persisted Local Server config. -- [ ] Expose a controller method to run a specific wizard step. -- [ ] Expose a controller method to apply Local Server runtime changes in the background. -- [ ] Expose a controller method to cancel the current Local Server job. -- [ ] Expose a controller method to open a terminal for the selected distro. - -## 03 Windows Local Runtime Path - -- [ ] Move the existing Windows local sidecar startup path from `packages/desktop-electron/src/main/server.ts` into the Local Server controller. -- [ ] Keep the existing Windows local runtime behavior unchanged when `mode === "windows"`. -- [ ] Keep the existing eager local startup behavior unchanged for Windows local mode. -- [ ] Keep the existing loopback no-proxy behavior unchanged for Windows local mode. -- [ ] Update the controller snapshot so Windows local mode reports a distinct runtime key instead of the old implicit bare `sidecar` assumption. - -## 04 WSL Process Helpers - -- [ ] Add a helper to spawn `wsl.exe` with a selected distro and stream stdout/stderr lines. -- [ ] Standardize all WSL command execution on `wsl.exe -d -- bash -lc ...`. -- [ ] Add a helper to kill the currently spawned WSL child process when a job is canceled. -- [ ] Do not call `wsl --terminate ` as part of normal cancel behavior. -- [ ] Add a helper to run a short command and collect stdout/stderr for probe steps. -- [ ] Add a helper to resolve the selected distro home directory via `~`. -- [ ] Add a helper to run `command -v opencode` inside the selected distro. -- [ ] Add a helper to resolve `opencode --version` inside the selected distro. -- [ ] Add a helper to detect the selected distro default username via shell commands. -- [ ] Add a helper to detect whether the selected distro default user is `root`. -- [ ] Add a helper to detect whether `bash` exists in the selected distro. -- [ ] Add a helper to detect whether `curl` exists in the selected distro. -- [ ] Add a helper to run `opencode upgrade ` inside the selected distro. -- [ ] Add a helper to launch the Local Server with a resolved absolute executable path instead of bare `opencode`. - -## 05 WSL Runtime and Distro Probes - -- [ ] Add a helper to probe whether `wsl.exe` is available and usable. -- [ ] Add a helper to list installed distros with `wsl --list --verbose`. -- [ ] Add a helper to list online distros with `wsl --list --online`. -- [ ] Do not add system-distro filtering logic; keep all returned distros visible. -- [ ] Add probe parsing for `WSL 1` vs `WSL 2` from installed distro data. -- [ ] Treat `WSL 2` as required for first-class onboarding. -- [ ] Add explicit probe output for `missing bash`. -- [ ] Add explicit probe output for `missing curl`. -- [ ] Add explicit probe output for `cannot execute commands in distro`. -- [ ] Add explicit probe output for `default user is root`. -- [ ] Add explicit probe output for `distro not found`. - -## 06 WSL Install Helpers - -- [ ] Add a helper to run elevated `wsl --install --no-distribution` from Electron main. -- [ ] Implement that elevation path with a shell-based helper invocation rather than a bundled helper binary. -- [ ] Add a helper to install a distro with `wsl --install -d --web-download --no-launch`. -- [ ] Do not auto-install a default distro as part of the WSL runtime install step. -- [ ] Add a helper to detect whether a WSL install requires reboot. -- [ ] Add a helper to expose a `Restart now` action. -- [ ] Add a helper to mark onboarding as pending reboot when the user chooses `Later`. - -## 07 OpenCode Runtime Detection and Repair - -- [ ] Resolve `command -v opencode` on each startup when `mode === "wsl"`. -- [ ] Re-resolve `command -v opencode` on each explicit WSL apply/retry action. -- [ ] Compare the detected WSL `opencode` version to the desktop app version. -- [ ] Record mismatch acknowledgement once per resolved path plus version pair. -- [ ] Keep version mismatch non-blocking. -- [ ] Treat `Use anyway` as sufficient to complete the OpenCode step with warning. -- [ ] Implement `Install matching version` by running `opencode upgrade ` first. -- [ ] If `opencode upgrade` hangs, prompts, or fails, mark the repair attempt failed and stop automation. -- [ ] Surface the failed upgrade transcript in the Local Server UI. -- [ ] Do not add an automatic fallback installer path after `opencode upgrade` fails. -- [ ] Surface manual recovery commands instead. - -## 08 Startup Handshake and App Boot - -- [ ] Replace the current success-only startup payload with a ready-or-failed startup union. -- [ ] Include local runtime metadata in the startup payload. -- [ ] Include the local runtime key in the startup payload. -- [ ] Include runtime variant details in the startup payload. -- [ ] Include selected distro in the startup payload when `mode === "wsl"`. -- [ ] Include loopback URL and credentials in the startup payload even when the local runtime later fails health. -- [ ] Include startup failure step and message in the startup payload when the local runtime fails. -- [ ] Update `packages/desktop-electron/src/main/index.ts` to initialize Local Server through the controller. -- [ ] Keep the loading overlay generic and do not add WSL-specific overlay phases in v1. -- [ ] Add a startup health-verdict timeout for WSL local startup so the app can open after failure. -- [ ] Scope the startup timeout to the local health verdict only, not to sqlite migration. -- [ ] Open the main window after startup reaches a ready-or-failed local verdict. - -## 09 IPC and Preload API - -- [ ] Add a namespaced `localServer` API to the Electron preload surface. -- [ ] Implement `localServer.getState()`. -- [ ] Implement `localServer.subscribe()` with unsubscribe support. -- [ ] Implement `localServer.setConfig()`. -- [ ] Implement `localServer.runStep()`. -- [ ] Implement `localServer.apply()` for background runtime switching. -- [ ] Implement `localServer.cancelJob()`. -- [ ] Implement `localServer.openTerminal()`. -- [ ] Implement `localServer.restartNow()` for reboot-required flows. -- [ ] Implement `localServer.copyTranscript()` or equivalent transcript fetch action. -- [ ] Emit typed step/state events from main to renderer. -- [ ] Emit raw stdout/stderr line events from main to renderer. -- [ ] Remove `getWslConfig` from the preload API. -- [ ] Remove `setWslConfig` from the preload API. -- [ ] Remove `get-wsl-config` and `set-wsl-config` IPC handlers. - -## 10 Renderer Startup and Platform Wiring - -- [ ] Update the desktop renderer startup resource to consume the new startup union shape. -- [ ] Build the Local Server `ServerConnection.Sidecar` from structured startup metadata instead of hardcoding `variant: "base"`. -- [ ] Keep the visible Local Server display name as `Local Server` in both Windows and WSL modes. -- [ ] Add a WSL badge or subtitle in the row UI instead of renaming the server. -- [ ] Change the implicit local fallback key to follow the configured Local Server runtime. -- [ ] Remove all uses of `window.__OPENCODE__.wsl` from the renderer. -- [ ] Derive WSL picker/path behavior from structured Local Server state instead of a global boolean. -- [ ] Update `createPlatform()` so WSL path conversion only activates when Local Server mode is WSL. -- [ ] Default native pickers to the selected distro home path when Local Server mode is WSL. -- [ ] Keep native pickers on normal Windows behavior when Local Server mode is Windows. - -## 11 Distro-Aware Path Conversion - -- [ ] Update `packages/desktop-electron/src/main/apps.ts` so `wslPath()` accepts a distro parameter. -- [ ] Stop using the ambient default WSL distro for path conversion. -- [ ] Use the selected Local Server distro for all `~` resolution. -- [ ] Use the selected Local Server distro for all Windows-to-Linux path conversion. -- [ ] Use the selected Local Server distro for all Linux-to-Windows path conversion. -- [ ] Update open-path behavior to use distro-aware conversion when Local Server mode is WSL. - -## 12 App Server Model Changes - -- [ ] Introduce a distinct explicit key for Windows Local Server. -- [ ] Keep WSL Local Server keyed by distro identity. -- [ ] Update `packages/app/src/context/server.tsx` so Windows local and WSL local do not collapse into the same project-history bucket. -- [ ] Update `projectsKey()` to keep Windows local and WSL local histories separate. -- [ ] Update `isLocal()` so WSL Local Server still counts as local. -- [ ] Ensure Local Server key changes force the expected remount behavior through `ServerKey` in `packages/app/src/app.tsx`. -- [ ] If Local Server is currently active, make successful runtime switches follow the new local key automatically. - -## 13 Manage Servers Dialog Shell - -- [ ] Add a pinned `Local Server` row to `packages/app/src/components/dialog-select-server.tsx` list mode. -- [ ] Extract a dedicated Local Server page component instead of growing `dialog-select-server.tsx` further. -- [ ] Add a dialog mode or route that opens the dedicated Local Server page from the server list. -- [ ] Keep existing HTTP add/edit/delete/default flows untouched while adding the Local Server entry. -- [ ] Add an initial-view prop so the Manage Servers dialog can open directly to Local Server. - -## 14 Local Server Wizard - -- [ ] Implement a dedicated Local Server wizard component. -- [ ] Implement step order exactly as `WSL -> Distro -> OpenCode -> Switch`. -- [ ] Allow the user to go back and edit earlier steps. -- [ ] Persist wizard progress inside `LocalServerConfig`. -- [ ] Auto-resume the wizard after app relaunch when onboarding is incomplete. -- [ ] Auto-resume the wizard after reboot when onboarding was waiting for restart. -- [ ] Mark the wizard complete only after Local Server hot restart succeeds and health passes. - -## 15 WSL Step UI - -- [ ] Show current WSL runtime probe result in the WSL step. -- [ ] Add an `Install WSL` action that starts elevated `wsl --install --no-distribution`. -- [ ] Show reboot-required state in the WSL step when the install path requires restart. -- [ ] Add `Restart now` and `Later` actions. -- [ ] Keep the WSL step editable after the user returns from reboot. - -## 16 Distro Step UI - -- [ ] Show installed distros with explicit probe status in the Distro step. -- [ ] Show quick install actions for `Debian` and `Ubuntu 24`. -- [ ] Show an `Other distro...` action that reads from the online distro list. -- [ ] After distro install, auto-select the newly installed distro and continue probing automatically. -- [ ] Surface `WSL 1` as unsupported with manual conversion instructions. -- [ ] Surface `missing bash` as an explicit unsupported reason. -- [ ] Surface `missing curl` as an explicit unsupported reason. -- [ ] Surface `cannot execute commands` as an explicit unsupported reason. -- [ ] Surface `default user is root` as a warning in the Distro step. -- [ ] Require explicit root acknowledgement once per distro. -- [ ] Keep all distros visible even when unsupported. -- [ ] If the selected distro disappears, show an explicit missing-distro error instead of auto-switching away. - -## 17 OpenCode Step UI - -- [ ] Show the resolved absolute `opencode` path for the selected distro. -- [ ] Show the detected `opencode` version for the selected distro. -- [ ] Show version mismatch as a non-blocking warning. -- [ ] Add `Use anyway` in the mismatch state. -- [ ] Add `Install matching version` in the mismatch state. -- [ ] Keep mismatch acknowledgement scoped to path plus version. -- [ ] Re-warn only when the resolved path or resolved version changes later. -- [ ] If `command -v opencode` resolves nothing, show explicit manual recovery guidance. -- [ ] If `opencode upgrade` fails, keep the step incomplete and show the transcript. - -## 18 Switch Step UI - -- [ ] Add a `Switch Local Server` action that applies the new Local Server runtime in the background. -- [ ] Keep remote sessions usable while the Switch step runs. -- [ ] Reuse the current app-launch port and password during background Local Server restarts. -- [ ] Keep the Local Server active selection on Local Server when the switch succeeds and Local Server was already active. -- [ ] Show success only after `/global/health` succeeds for the new runtime. -- [ ] If background apply fails, show a `Restart OpenCode` fallback prompt. - -## 19 Local Server Status Dashboard - -- [ ] Replace the wizard with a steady-state dashboard after onboarding completes. -- [ ] Show current mode on the dashboard. -- [ ] Show selected distro on the dashboard when in WSL mode. -- [ ] Show current Local Server health on the dashboard. -- [ ] Show current failure state on the dashboard when the last startup or apply failed. -- [ ] Show version mismatch warning on the dashboard when the user chose `Use anyway`. -- [ ] Show root warning on the dashboard when the selected distro is root-backed and acknowledged. -- [ ] Do not show stale last-known-good probe values outside the current failure context. -- [ ] Add dashboard actions for `Retry`, `Open terminal`, and transcript copy. - -## 20 Live Diagnostics - -- [ ] Add a live diagnostics panel to the Local Server UI. -- [ ] Stream merged stdout/stderr lines into the panel while jobs are running. -- [ ] Keep the panel usable for startup failures from the current app launch. -- [ ] Retain the full Local Server transcript only for the current app launch. -- [ ] Clear the retained transcript on full app relaunch. -- [ ] Show exact commands in the diagnostics details area. -- [ ] Make `Copy commands` copy the same transcript content as the transcript-copy action. -- [ ] Keep diagnostics collapsible by default. - -## 21 Connection Error and Deep Linking - -- [ ] Add a direct `Open Local Server` CTA to the existing `ConnectionError` screen when the failing server is Local Server. -- [ ] Make that CTA open the Manage Servers dialog directly to the Local Server page. -- [ ] When Local Server startup failed earlier in the same launch, jump the Local Server UI directly to the failing step or dashboard state. -- [ ] Keep existing retry behavior for non-local remote servers unchanged. - -## 22 Runtime Apply and Background Behavior - -- [ ] Apply Local Server runtime changes in the background when the active server is remote. -- [ ] Do not navigate away from a remote session during a Local Server background apply. -- [ ] Keep Local Server config separate from active/default server selection logic. -- [ ] Do not auto-select Local Server just because its runtime config changed. -- [ ] Do not auto-change the user's default remote server selection when Local Server mode changes. - -## 23 Main Window Startup Failure Handling - -- [ ] If WSL Local Server fails during startup, keep the app launch going after the health-verdict timeout. -- [ ] Represent that failure in the startup payload instead of throwing away initialization. -- [ ] Keep the Local Server row present in the server list even when startup failed. -- [ ] Mark the Local Server row unhealthy when startup failed. -- [ ] Keep the startup loading overlay generic even in this failed case. - -## 24 API Cleanup and Legacy Removal - -- [ ] Remove the old hidden WSL settings UI branch from `packages/app/src/components/settings-general.tsx`. -- [ ] Remove legacy renderer calls that assume a boolean WSL mode. -- [ ] Remove legacy IPC registrations for the boolean WSL config. -- [ ] Remove legacy preload typing for the boolean WSL config. -- [ ] Remove legacy main-process store helpers that only read/write `wslEnabled`. - -## 25 Manual Recovery and Power Actions - -- [ ] Implement `Open terminal` as `open selected distro shell only` and do not auto-run recovery commands. -- [ ] Make `Open terminal` target the selected distro explicitly. -- [ ] Add a transcript copy action that is available even after failed jobs. -- [ ] Keep manual recovery command text aligned with the actual commands the controller runs. -- [ ] Include manual commands for WSL 1 conversion in the Distro step. -- [ ] Include manual commands for missing `curl` in the Distro or OpenCode step as appropriate. -- [ ] Include manual commands for PATH install version repair in the OpenCode step failure state. - -## 26 Verification and QA - -- [ ] Verify Windows Local Server behavior is unchanged when `mode === "windows"`. -- [ ] Verify the app still boots normally with no Local Server config present. -- [ ] Verify the app opens after a WSL startup failure instead of hanging forever. -- [ ] Verify `Install WSL` can reach a reboot-required state and resume after relaunch. -- [ ] Verify `Restart now` and `Later` both preserve onboarding state correctly. -- [ ] Verify Debian quick install auto-selects the new distro and continues onboarding. -- [ ] Verify Ubuntu 24 quick install auto-selects the new distro and continues onboarding. -- [ ] Verify `Other distro...` uses the live online catalog. -- [ ] Verify a WSL 1 distro surfaces manual conversion instructions. -- [ ] Verify a distro missing `bash` surfaces an explicit unsupported reason. -- [ ] Verify a distro missing `curl` surfaces an explicit unsupported reason. -- [ ] Verify a root-backed distro requires acknowledgement once per distro. -- [ ] Verify PATH-installed `opencode` is re-resolved on each startup. -- [ ] Verify mismatch acknowledgement only reappears when path or version changes. -- [ ] Verify `Use anyway` completes onboarding with a lingering dashboard warning. -- [ ] Verify `Install matching version` runs `opencode upgrade `. -- [ ] Verify an upgrade hang or prompt can be canceled and leaves a usable transcript. -- [ ] Verify Local Server hot restart keeps the same port and password within one app launch. -- [ ] Verify Local Server hot restart does not interrupt an active remote session. -- [ ] Verify active Local Server selection follows the new local key after a successful runtime switch. -- [ ] Verify Windows local and WSL local project histories remain separate. -- [ ] Verify the `ConnectionError` CTA opens the Local Server page directly. -- [ ] Verify selected-distro path conversion is used everywhere in WSL mode. -- [ ] Verify selected-distro home is used as the picker default in WSL mode. -- [ ] Verify deleting the selected distro produces an explicit error instead of silent fallback. -- [ ] Verify transcripts are only retained for the current app launch. +## Direction + +- Local Server is **always** Windows-native local on Windows. +- Local Server has no runtime swap and no WSL mode. +- WSL servers are a **separate, additive** concept. +- A Windows user can add zero or more WSL servers; each is bound to a specific distro. +- Each WSL server runs as its own sidecar alongside the Windows Local Server. +- Adding/removing a WSL server is hot; no app restart required. +- Manage Servers UI exposes an `Add WSL` button (Windows only) that opens the wizard. + +## 01 Electron Config Split + +- [x] Remove runtime `mode` / `distro` from the persisted Local Server config. +- [x] Introduce a new persisted `wslServers` key holding an array of `WslServerConfig`. +- [x] Define `WslServerConfig` as `{ id, distro, onboarding, acknowledgements }`. +- [x] Keep onboarding metadata per WSL server, not globally. +- [x] Keep acknowledgement state per WSL server. +- [x] Migrate any legacy `localServer.mode === "wsl"` entry into a single `wslServers` entry on read. +- [x] Drop all references to `LocalServerMode` from the preload types. + +## 02 Main-Process Multi-Sidecar Startup + +- [x] Always start the Windows local sidecar on app launch. +- [x] After Windows local is spawned, iterate each `WslServerConfig` and spawn a WSL sidecar per entry. +- [x] Give each WSL sidecar its own port and password. +- [x] Allocate the Windows local port/password once per launch (unchanged). +- [x] Track all sidecars in a single map keyed by server id. +- [x] Kill all sidecars on `before-quit` / `will-quit` / signal. +- [x] Include Windows local data in the startup payload unchanged. +- [x] Include the initial set of WSL servers (with url/password/status) in the startup payload. +- [x] Emit per-WSL-server lifecycle events (`starting`, `ready`, `failed`, `stopping`, `removed`). +- [x] On startup, do not block the main window on WSL sidecar health. +- [x] If a WSL sidecar fails health, keep it in the list and mark it failed instead of hanging startup. + +## 03 WSL Server Controller + +- [x] Create a main-process controller that owns `wslServers` persistence and runtime state. +- [x] Expose typed events (`state`, per-item status changes) from the controller. +- [x] Support one in-flight job per WSL server (not a global in-flight job). +- [x] Implement `addServer(distro)` that persists, then spawns and health-checks a new sidecar. +- [x] Implement `removeServer(id)` that stops the sidecar and removes it from config. +- [x] Implement per-server `runStep`, `cancelJob`, `installWsl`, `installDistro`, `installOpencode`, `openTerminal`. +- [x] Reuse the existing WSL process helpers unchanged. +- [x] Keep transcripts per server, only for the current app launch. + +## 04 IPC / Preload Surface + +- [x] Rename `localServer.*` IPC channels to `wslServers.*`. +- [x] Add `wslServers.getState()` returning the full list plus per-server runtime info. +- [x] Add `wslServers.subscribe()` with unsubscribe support. +- [x] Add `wslServers.add(distro)` (persists config + starts sidecar). +- [x] Add `wslServers.remove(id)`. +- [x] Add `wslServers.runStep(id, step)`. +- [x] Add `wslServers.cancelJob(id)`. +- [x] Add `wslServers.installWsl(id)`. +- [x] Add `wslServers.installDistro(id, distro)`. +- [x] Add `wslServers.installOpencode(id)`. +- [x] Add `wslServers.openTerminal(id)`. +- [x] Remove obsolete `localServer.setConfig` / `localServer.*` channels. +- [x] Include url/username/password for each WSL server in the state payload (after sidecar start). + +## 05 Renderer Platform Wiring + +- [x] Expose `platform.wslServers` as a reactive accessor (list + subscribe). +- [x] Remove `platform.localServer` runtime swap APIs. +- [x] Keep `platform.wslServers` API available on Windows only. +- [x] Keep distro-aware path conversion keyed by the active WSL server. +- [x] When the active server is a WSL sidecar, default pickers to that distro's home. +- [x] When the active server is the Windows Local Server, keep native Windows picker defaults. + +## 06 Renderer Server List + +- [x] Always include the Windows Local Server in the server list. +- [x] Include each configured WSL server in the server list with `ServerConnection.Sidecar` variant `wsl`. +- [x] Keep `ServerConnection.key` returning `local:windows` for the Windows Local Server. +- [x] Keep `ServerConnection.key` returning `wsl:` for WSL servers (one per distro). +- [x] Keep distinct `projectsKey` buckets for Windows local vs each WSL server. +- [x] Do not collapse a WSL server into the `local` projects bucket. + +## 07 Manage Servers UI + +- [x] Remove `Swap to WSL` and `Swap to Windows` buttons from the Local Server row. +- [x] Show the Local Server row exactly like any other server (health, name, active check). +- [x] Add an `Add WSL` button next to `Add server`, visible only on Windows when the platform supports WSL. +- [x] `Add WSL` opens the same wizard stepper, scoped to a new WSL server draft. +- [x] Each WSL server row behaves like a sidecar entry (selectable, default-able, removable). +- [x] Add a `Remove` action to the WSL server row menu. +- [x] Add a `Retry setup` action when a WSL server is unhealthy. + +## 08 Add WSL Wizard + +- [x] Replace the "Switch" step with a `Done` step. +- [x] Step order becomes `WSL -> Distro -> OpenCode -> Done`. +- [x] On `Done`, persist the new WSL server, start the sidecar, and close the dialog. +- [x] If the user cancels, do not persist anything. +- [x] Allow resuming an incomplete WSL server wizard from Manage Servers. +- [x] Remove restart-to-apply copy, restart toasts, and "Use Windows" CTA. +- [x] Keep failure-only diagnostics panel behavior. + +## 09 Per-WSL Onboarding State + +- [x] Keep `WslServerConfig.onboarding` per server. +- [x] Keep `WslServerConfig.acknowledgements` per server. +- [x] Resume the wizard for any server where `onboarding.complete === false`. +- [x] Mark onboarding complete only after the sidecar becomes healthy. + +## 10 Connection Error Path + +- [x] If the active server is a WSL sidecar and health fails, offer `Open setup` that deep-links into the wizard for that server. +- [x] Keep behavior unchanged for the Windows Local Server. +- [x] Keep behavior unchanged for remote HTTP servers. + +## 11 Legacy Removal + +- [x] Remove `Swap to WSL` / `Swap to Windows` popover components. +- [x] Remove `restart-to-apply` banner copy and helpers from `dialog-local-server.tsx`. +- [x] Remove legacy `wslEnabled` preload shims. +- [x] Remove the old `DialogSelectServer` `initialTargetMode` prop. +- [x] Drop `localServerKey(config)` distinguishing `wsl` vs `windows` in the controller (local is always Windows). + +## 12 Verification + +- [ ] Verify the Windows Local Server starts unchanged on app launch. +- [ ] Verify `Add WSL` opens the wizard with the default installed distro preselected. +- [ ] Verify adding a WSL server spawns a new sidecar without restarting the app. +- [ ] Verify removing a WSL server stops the sidecar and removes it from the list. +- [ ] Verify multiple WSL servers can coexist, one per distro. +- [ ] Verify Windows Local Server stays active while WSL sidecars come and go. +- [ ] Verify a failed WSL sidecar does not block app startup or window creation. +- [ ] Verify the `ConnectionError` deep-link reaches the right wizard scope. +- [ ] Verify legacy `localServer.mode === "wsl"` persisted config migrates into `wslServers` on first launch. +- [ ] Verify project histories stay separate per server key. From 66dfdb933d39638e846e81b2f7f87a7a689db832 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:56:15 +1000 Subject: [PATCH 40/88] fix: pin desktop renderer dev server to loopback --- packages/desktop-electron/electron.vite.config.ts | 7 +++++++ packages/desktop-electron/src/main/windows.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index d0e6c42b6c8c..267d6c653900 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -60,6 +60,13 @@ export default defineConfig({ plugins: [appPlugin], publicDir: "../../../app/public", root: "src/renderer", + server: { + host: "127.0.0.1", + strictPort: true, + hmr: { + host: "127.0.0.1", + }, + }, define: { "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 95f80c124064..b5cdf6c4d8b2 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -134,7 +134,9 @@ export function createLoadingWindow(globals: Globals) { function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { - const url = new URL(html, devUrl) + const base = new URL(devUrl) + if (base.hostname === "localhost") base.hostname = "127.0.0.1" + const url = new URL(html, base) void win.loadURL(url.toString()) return } From 12fa7821372ac14352e4ca3053e844dbe19bd4aa Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:56:28 +1000 Subject: [PATCH 41/88] refactor: treat WSL servers as additive alongside windows local Local Server is always Windows-native now; WSL lives as a separate list of one-or-more distro-bound sidecars spawned alongside it. Manage Servers shows an Add WSL button on Windows, each WSL server appears as its own row with remove + retry, and the wizard runs scoped to a new distro. --- packages/app/src/app.tsx | 10 +- .../src/components/dialog-select-server.tsx | 285 +++----- ...local-server.tsx => dialog-wsl-server.tsx} | 402 +++++------ packages/app/src/context/platform.tsx | 139 ++-- packages/app/src/context/server.tsx | 7 + packages/app/src/index.ts | 18 +- .../desktop-electron/src/main/constants.ts | 3 +- packages/desktop-electron/src/main/index.ts | 126 ++-- packages/desktop-electron/src/main/ipc.ts | 76 +- .../desktop-electron/src/main/local-server.ts | 678 ------------------ packages/desktop-electron/src/main/server.ts | 86 ++- .../desktop-electron/src/main/wsl-servers.ts | 483 +++++++++++++ packages/desktop-electron/src/main/wsl.ts | 15 +- .../desktop-electron/src/preload/index.ts | 33 +- .../desktop-electron/src/preload/types.ts | 136 ++-- .../desktop-electron/src/renderer/env.d.ts | 2 +- .../desktop-electron/src/renderer/index.tsx | 109 +-- 17 files changed, 1166 insertions(+), 1442 deletions(-) rename packages/app/src/components/{dialog-local-server.tsx => dialog-wsl-server.tsx} (58%) delete mode 100644 packages/desktop-electron/src/main/local-server.ts create mode 100644 packages/desktop-electron/src/main/wsl-servers.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e0f8a1481849..362ac271e943 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -76,7 +76,7 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] - wsl?: boolean + activeServer?: string } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise @@ -234,7 +234,7 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: const name = createMemo(() => server.name || server.key) const serverToken = "\u0000server\u0000" const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) - const canOpenLocalServer = createMemo(() => !!platform.localServer && server.current?.type === "sidecar") + const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") const timer = setInterval(() => props.onRetry?.(), 1000) onCleanup(() => clearInterval(timer)) @@ -249,18 +249,18 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: {unreachable()[1]}

{language.t("app.server.retrying")}

- +
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 71b53bee1f8a..01cb280e5df7 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -5,24 +5,22 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" -import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" -import { DialogLocalServer } from "@/components/dialog-local-server" +import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" -import { type LocalServerConfig, usePlatform } from "@/context/platform" +import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" interface DialogSelectServerProps { - initialView?: "list" | "local" - initialTargetMode?: "windows" | "wsl" + initialView?: "list" | "add-wsl" onNavigateHome?: () => void } @@ -197,10 +195,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showForm: false, status: undefined as boolean | undefined, }, - localServer: { - showPage: props.initialView === "local", - targetMode: props.initialTargetMode as "windows" | "wsl" | undefined, - confirmSwapKey: undefined as ServerConnection.Key | undefined, + addWsl: { + showWizard: props.initialView === "add-wsl", }, editServer: { id: undefined as string | undefined, @@ -430,8 +426,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { ) } - const mode = createMemo<"list" | "local" | "add" | "edit">(() => { - if (store.localServer.showPage) return "local" + const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => { + if (store.addWsl.showWizard) return "add-wsl" if (store.editServer.id) return "edit" if (store.addServer.showForm) return "add" return "list" @@ -445,12 +441,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const resetForm = () => { resetAdd() resetEdit() - setStore("localServer", "showPage", false) - setStore("localServer", "targetMode", undefined) + setStore("addWsl", "showWizard", false) } const startAdd = () => { - setStore("localServer", "showPage", false) + setStore("addWsl", "showWizard", false) resetEdit() setStore("addServer", { showForm: true, @@ -464,7 +459,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } const startEdit = (conn: ServerConnection.Http) => { - setStore("localServer", "showPage", false) + setStore("addWsl", "showWizard", false) resetAdd() setStore("editServer", { id: conn.http.url, @@ -477,65 +472,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) } - const startLocal = (targetMode?: "windows" | "wsl") => { + const startAddWsl = () => { resetAdd() resetEdit() - setStore("localServer", "showPage", true) - setStore("localServer", "targetMode", targetMode) - setStore("localServer", "confirmSwapKey", undefined) - } - - const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(config) - - const swapLocalToWindows = async () => { - const localServer = platform.localServer - if (!localServer) return - - try { - const state = await localServer.getState() - const config = plainConfig(state.config) - await localServer.setConfig({ - ...config, - mode: "windows", - distro: null, - onboarding: { - ...config.onboarding, - complete: true, - pendingRestart: false, - step: null, - }, - }) - setStore("localServer", "confirmSwapKey", undefined) - showToast({ - variant: "success", - title: "Local Server set to Windows", - description: "Restart OpenCode to finish switching back to Windows.", - persistent: true, - actions: [ - { - label: "Restart", - onClick: () => void platform.restart(), - }, - ], - }) - } catch (err) { - console.error("Local Server 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), - }) - } - } - - const localSwapLabel = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar") return "" - return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL" - } - - const localSwapTarget = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar") return undefined - return conn.variant === "wsl" ? "windows" : "wsl" + setStore("addWsl", "showWizard", true) } const submitForm = () => { @@ -554,8 +494,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") - const isLocalMode = createMemo(() => mode() === "local") + const isAddWslMode = createMemo(() => mode() === "add-wsl") const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) + const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows") const formTitle = createMemo(() => { if (!isFormMode()) return language.t("dialog.server.title") @@ -563,8 +504,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
- {isLocalMode() - ? "Local Server" + {isAddWslMode() + ? "Add WSL server" : isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} @@ -579,21 +520,44 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { resetEdit() }) - async function handleRemove(url: ServerConnection.Key) { - server.remove(url) - if ((await platform.getDefaultServer?.()) === url) { + async function handleRemove(key: ServerConnection.Key) { + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { void platform.setDefaultServer?.(null) } } + async function handleRemoveWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + const key = ServerConnection.key(conn) + try { + await platform.wslServers?.removeServer(key) + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { + void platform.setDefaultServer?.(null) + } + } catch (err) { + showRequestError(language, err) + } + } + + async function handleRetryWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + try { + await platform.wslServers?.startServer(ServerConnection.key(conn)) + } catch (err) { + showRequestError(language, err) + } + } + return ( - +
} > - + } > @@ -625,7 +589,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { noInitialSelection emptyMessage={language.t("dialog.server.empty")} items={sortedItems} - key={(x) => x.http.url} + key={(x) => ServerConnection.key(x)} onSelect={(x) => { if (x) void select(x) }} @@ -634,6 +598,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { > {(i) => { const key = ServerConnection.key(i) + const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" return (
@@ -654,85 +619,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
- - {(() => { - const sidecar = i as ServerConnection.Sidecar - if (sidecar.variant !== "wsl") { - return ( - - ) - } - - return ( - - setStore( - "localServer", - "confirmSwapKey", - open ? ServerConnection.key(sidecar) : undefined, - ) - } - triggerAs={Button} - triggerProps={{ - variant: "secondary", - size: "small", - class: "shrink-0", - onClick: (event: MouseEvent) => event.stopPropagation(), - onPointerDown: (event: PointerEvent) => event.stopPropagation(), - }} - trigger={localSwapLabel(sidecar)} - placement="bottom-end" - portal={false} - > -
-
Use Windows instead?
-
- Restart OpenCode after switching the Local Server back to Windows. -
-
- - -
-
-
- ) - })()} -
- - + - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + + void handleRetryWsl(i)}> + Retry start + + + setDefault(key)}> {language.t("dialog.server.menu.default")} - + setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - + + + (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + + {language.t("dialog.server.menu.delete")} + + + @@ -786,18 +688,31 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
- + +
+ + + + +
} > diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx similarity index 58% rename from packages/app/src/components/dialog-local-server.tsx rename to packages/app/src/components/dialog-wsl-server.tsx index 9c8c9b0a65bc..49ac18023869 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -1,29 +1,37 @@ 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, on, onCleanup, Show, Switch } from "solid-js" -import { createStore, reconcile, unwrap } from "solid-js/store" +import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerConfig, LocalServerMode, LocalServerState, LocalServerStep } from "@/context/platform" +import type { WslServerStep, WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" -const WSL_STEPS: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] +const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"] -export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { +interface DialogWslServerProps { + onAdded?: () => void +} + +export function DialogWslServer(props: DialogWslServerProps = {}) { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() const [store, setStore] = createStore({ - state: undefined as LocalServerState | undefined, + state: undefined as WslServersState | undefined, loading: true, - step: undefined as LocalServerStep | undefined, + step: undefined as WslServerStep | undefined, + selectedDistro: null as string | null, installTarget: undefined as string | undefined, + adding: false, }) createEffect(() => { - const localServer = platform.localServer - if (!localServer) return + const wslServers = platform.wslServers + if (!wslServers) return let mounted = true - void localServer + void wslServers .getState() .then((state) => { if (!mounted) return @@ -34,7 +42,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { requestError(language, err) setStore("loading", false) }) - const off = localServer.subscribe((event) => { + const off = wslServers.subscribe((event) => { setStore("state", reconcile(event.state)) setStore("loading", false) }) @@ -45,25 +53,24 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { }) const current = () => store.state - const localServer = () => platform.localServer - const targetMode = createMemo<"windows" | "wsl">( - () => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"), - ) - const configuredDistro = createMemo(() => current()?.config.distro ?? null) - const busy = createMemo(() => !!current()?.job) + const wslServers = () => platform.wslServers + const busy = createMemo(() => !!current()?.job || store.adding) + const selectedDistro = () => store.selectedDistro const selectedProbe = createMemo(() => { - const probe = current()?.checks.distro?.selected - return probe?.name === configuredDistro() ? probe : null + const distro = selectedDistro() + if (!distro) return null + return current()?.distroProbes[distro] ?? null }) - const selectedInstalled = createMemo(() => - (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), - ) - const defaultInstalledDistro = createMemo( - () => (current()?.checks.distro?.installed ?? []).find((item) => item.isDefault) ?? null, - ) + const selectedInstalled = createMemo(() => { + const distro = selectedDistro() + if (!distro) return null + return (current()?.installed ?? []).find((item) => item.name === distro) ?? null + }) + const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null) const opencodeCheck = createMemo(() => { - const check = current()?.checks.opencode - return check?.distro === configuredDistro() ? check : null + const distro = selectedDistro() + if (!distro) return null + return current()?.opencodeChecks[distro] ?? null }) const distroWarningProbe = createMemo(() => { const probe = selectedProbe() @@ -73,7 +80,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { }) const distroUnavailableMessage = createMemo(() => { const probe = distroWarningProbe() - const distro = configuredDistro() + const distro = selectedDistro() if (!probe || probe.canExecute || !distro) return null if (!selectedInstalled()) return `${distro} is not installed yet.` return `Open ${distro} once to finish setup.` @@ -88,36 +95,23 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const check = opencodeCheck() return check?.matchesDesktop === false ? check : null }) + const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro))) + const addableInstalledDistros = createMemo(() => { + return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name)) + }) const installableDistros = createMemo(() => { - const online = current()?.checks.distro?.online ?? [] - const installed = new Set((current()?.checks.distro?.installed ?? []).map((item) => item.name)) + const online = current()?.online ?? [] + const installed = new Set((current()?.installed ?? []).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) ?? null) - const configuredRuntime = createMemo(() => { - const state = current() - if (!state) return { mode: "windows" as const, distro: null as string | null } - if (state.config.mode === "wsl" && state.config.distro) { - return { mode: "wsl" as const, distro: state.config.distro } - } - return { mode: "windows" as const, distro: null as string | null } - }) - const configuredRuntimeLabel = createMemo(() => runtimeLabel(configuredRuntime().mode, configuredRuntime().distro)) - const currentRuntimeLabel = createMemo(() => - runtimeLabel(current()?.runtime.mode ?? "windows", current()?.runtime.distro ?? null), - ) - const needsRestart = createMemo(() => { - const state = current() - if (!state) return false - return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro - }) - const wslReady = createMemo(() => !!current()?.checks.wsl?.available && !current()?.config.onboarding.pendingRestart) + const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() - if (!probe || !current()?.config.distro) return false + if (!probe || !selectedDistro()) return false if (selectedInstalled()?.version === 1) return false return probe.canExecute && probe.hasBash && probe.hasCurl }) @@ -125,15 +119,13 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const check = opencodeCheck() return !!check?.resolvedPath && !check.error }) - const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) - const recommendedStep = createMemo(() => { - if (targetMode() === "windows") return "switch" + const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) + const recommendedStep = createMemo(() => { if (!wslReady()) return "wsl" if (!distroReady()) return "distro" - if (!opencodeReady()) return "opencode" - return "switch" + return "opencode" }) - const activeStep = createMemo(() => current()?.job?.step ?? store.step ?? recommendedStep()) + const activeStep = createMemo(() => store.step ?? recommendedStep()) createEffect( on(recommendedStep, (next) => { @@ -143,17 +135,20 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const autoProbe = createMemo(() => { const state = current() - if (!state || !localServer() || busy() || targetMode() === "windows") return null - if (state.config.onboarding.pendingRestart) return null - if (!state.checks.wsl) return { key: "wsl", step: "wsl" as const } + if (!state || !wslServers() || busy()) return null + if (state.pendingRestart) return null + if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() } if (!wslReady()) return null - if (!state.checks.distro) return { key: "distro:list", step: "distro" as const } - if (state.config.distro && !selectedProbe()) { - return { key: `distro:${state.config.distro}`, step: "distro" as const } + if (!state.installed.length && !state.online.length) { + return { key: "distros", run: () => wslServers()!.refreshDistros() } + } + const distro = selectedDistro() + if (distro && !state.distroProbes[distro]) { + return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) } } - if (!state.config.distro || !distroReady()) return null - if (!opencodeCheck()) { - return { key: `opencode:${state.config.distro}`, step: "opencode" as const } + if (!distro || !distroReady()) return null + if (!state.opencodeChecks[distro]) { + return { key: `probe-opencode:${distro}`, run: () => wslServers()!.probeOpencode(distro) } } return null }) @@ -163,15 +158,16 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const probe = autoProbe() if (!probe || probe.key === lastAutoProbe) return lastAutoProbe = probe.key - void run(() => localServer()!.runStep(probe.step)) + void run(probe.run) }) createEffect(() => { const state = current() const distro = defaultInstalledDistro() - if (!state || !distro || !localServer() || busy() || targetMode() !== "wsl") return - if (state.config.distro) return - void selectDistro(distro.name) + if (!state || !distro || busy()) return + if (selectedDistro()) return + if (existingServerDistros().has(distro.name)) return + setStore("selectedDistro", distro.name) }) createEffect(() => { @@ -186,43 +182,43 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { const wslMessage = createMemo(() => { const state = current() - if (!state || state.job?.step === "wsl") return "Checking WSL..." - if (state.config.onboarding.pendingRestart) return "Windows needs a restart to finish installing WSL." - if (state.checks.wsl?.available) return state.checks.wsl.version ?? "WSL is ready." - return state.checks.wsl?.error ?? "WSL is required to continue." + if (!state || state.job?.kind === "runtime") return "Checking WSL..." + if (state.pendingRestart) return "Windows needs a restart to finish installing WSL." + if (state.runtime?.available) return state.runtime.version ?? "WSL is ready." + return state.runtime?.error ?? "WSL is required to continue." }) const distroMessage = createMemo(() => { const state = current() if (!state) return "Checking distros..." - if (state.job?.step === "distro") { - if (state.config.distro && !selectedInstalled()) return `Installing ${state.config.distro}...` - return state.config.distro ? `Checking ${state.config.distro}...` : "Checking distros..." - } + const distro = selectedDistro() + if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...` + if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...` + if (state.job?.kind === "distros") return "Listing distros..." if (distroUnavailableMessage()) return distroUnavailableMessage()! - if (state.checks.distro?.error && !selectedProbe()) return state.checks.distro.error if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.` - if (state.config.distro) return `Finishing setup for ${state.config.distro}.` + if (distro) return `Finishing setup for ${distro}.` return "Pick a distro or install one below." }) const opencodeMessage = createMemo(() => { const state = current() if (!state) return "Checking OpenCode..." - if (state.job?.step === "opencode") { - return state.config.distro ? `Checking OpenCode in ${state.config.distro}...` : "Checking OpenCode..." + const distro = selectedDistro() + if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") { + return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..." } if (opencodeCheck()?.error) return opencodeCheck()!.error if (opencodeCheck()?.matchesDesktop === false) { - return state.config.distro ? `Update OpenCode in ${state.config.distro}.` : "Update OpenCode." + return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode." } - if (opencodeReady()) - return state.config.distro ? `OpenCode is ready in ${state.config.distro}.` : "OpenCode is ready." - return state.config.distro ? `Install OpenCode in ${state.config.distro}.` : "Choose a distro first." + if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready." + return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first." }) + const installProgress = createMemo(() => { const state = current() - if (!state?.job || state.status.kind !== "running") return null + if (!state?.job) return null const transcript = state.transcript.filter((line) => line.text.trim()) const title = transcript[0]?.text if (!title?.startsWith("Installing ")) return null @@ -240,48 +236,30 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { } } - const plainConfig = (config: LocalServerConfig): LocalServerConfig => structuredClone(unwrap(config)) - - const selectDistro = async (name: string) => { - const state = current() - if (!state || !localServer()) return - const config = plainConfig(state.config) + const selectDistro = (name: string) => { + setStore("selectedDistro", name) setStore("step", "distro") - await run(() => - localServer()!.setConfig({ - ...config, - mode: "wsl", - distro: name, - onboarding: { - ...config.onboarding, - complete: false, - step: "distro", - }, - }), - ) } - const swapToWindows = async () => { - const state = current() - if (!state || !localServer()) return - const config = plainConfig(state.config) - await run(() => - localServer()!.setConfig({ - ...config, - mode: "windows", - distro: null, - onboarding: { - ...config.onboarding, - complete: true, - pendingRestart: false, - step: null, - }, - }), - ) + const finish = async () => { + const distro = selectedDistro() + if (!distro) return + const api = wslServers() + if (!api) return + setStore("adding", true) + try { + await api.addServer(distro) + props.onAdded?.() + dialog.close() + } catch (err) { + requestError(language, err) + } finally { + setStore("adding", false) + } } const steps = createMemo(() => - WSL_STEPS.filter((step) => targetMode() === "wsl" || step === "switch").map((step) => ({ + STEPS.map((step) => ({ step, title: stepTitle(step), state: stepState(step, { @@ -290,8 +268,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { distroReady: distroReady(), opencodeReady: opencodeReady(), opencodeMismatch: opencodeCheck()?.matchesDesktop === false, - switchReady: switchReady(), - needsRestart: needsRestart(), }), locked: stepIndex(step) > stepIndex(recommendedStep()), })), @@ -299,51 +275,46 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { return (
- Loading local server...
} - > - -
- - {(item) => ( - - )} - -
-
+ Loading...
}> +
+ + {(item) => ( + + )} + +
WSL
- +
{wslMessage()}
- +
Windows restart required.
@@ -472,7 +447,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { variant="secondary" size="large" disabled={busy() || !selectedInstalled()} - onClick={() => void run(() => localServer()!.openTerminal())} + onClick={() => { + const distro = selectedDistro() + if (!distro) return + void run(() => wslServers()!.openTerminal(distro)) + }} > Open terminal @@ -488,7 +467,11 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { variant="secondary" size="large" disabled={busy()} - onClick={() => void run(() => localServer()!.installOpencode())} + onClick={() => { + const distro = selectedDistro() + if (!distro) return + void run(() => wslServers()!.installOpencode(distro)) + }} > {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} @@ -513,51 +496,6 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
- - -
-
-
Switch
-
- - - - -
-
- -
- {targetMode() === "windows" - ? needsRestart() - ? "Restart OpenCode to switch back to Windows." - : "Windows Local Server is active." - : needsRestart() - ? `Restart OpenCode to start using ${configuredRuntimeLabel()}.` - : `${configuredRuntimeLabel()} is active.`} -
- -
-
- After restart: {configuredRuntimeLabel()} -
-
- Using now: {currentRuntimeLabel()} -
- -
Complete the earlier steps first.
-
-
-
-
@@ -595,7 +533,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) { )} - 0}> + 0}>
Diagnostics
@@ -603,13 +541,27 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
+ +
+ + +
) } function requestError(language: ReturnType, err: unknown) { - console.error("Local Server request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) + console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err)) showToast({ variant: "error", title: language.t("common.requestFailed"), @@ -617,47 +569,35 @@ function requestError(language: ReturnType, err: unknown) { }) } -function stepIndex(step: LocalServerStep) { - return WSL_STEPS.indexOf(step) +function stepIndex(step: WslServerStep) { + return STEPS.indexOf(step) } -function stepTitle(step: LocalServerStep) { +function stepTitle(step: WslServerStep) { if (step === "wsl") return "WSL" if (step === "distro") return "Choose distro" - if (step === "opencode") return "OpenCode" - return "Switch" -} - -function runtimeLabel(mode: LocalServerMode, distro: string | null) { - if (mode === "windows") return "Windows" - return distro ? `WSL on ${distro}` : "WSL" + return "OpenCode" } function stepState( - step: LocalServerStep, + step: WslServerStep, state: { - active: LocalServerStep + active: WslServerStep wslReady: boolean distroReady: boolean opencodeReady: boolean opencodeMismatch: boolean - switchReady: boolean - needsRestart: boolean }, ) { if (state.active === step) return "current" if (step === "wsl") return state.wslReady ? "done" : "warning" if (step === "distro") return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" - if (step === "opencode") - return state.opencodeMismatch - ? "warning" - : state.opencodeReady - ? "done" - : stepIndex(step) > stepIndex(state.active) - ? "locked" - : "warning" - if (state.switchReady && !state.needsRestart) return "done" - if (stepIndex(step) > stepIndex(state.active)) return "locked" - return "warning" + return state.opencodeMismatch + ? "warning" + : state.opencodeReady + ? "done" + : stepIndex(step) > stepIndex(state.active) + ? "locked" + : "warning" } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 0c78e65eb106..75e04a4a5b9f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,29 +9,25 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } -export type LocalServerMode = "windows" | "wsl" -export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" -export type LocalServerMismatchAcknowledgement = { - path: string - version: string -} -export type LocalServerWslCheck = { +export type WslServerStep = "wsl" | "distro" | "opencode" + +export type WslRuntimeCheck = { available: boolean version: string | null status: string | null error: string | null } -export type LocalServerInstalledDistro = { +export type WslInstalledDistro = { name: string state: string | null version: number | null isDefault: boolean } -export type LocalServerOnlineDistro = { +export type WslOnlineDistro = { name: string label: string } -export type LocalServerDistroProbe = { +export type WslDistroProbe = { name: string canExecute: boolean hasBash: boolean @@ -40,73 +36,82 @@ export type LocalServerDistroProbe = { isRoot: boolean | null error: string | null } -export type LocalServerDistroCheck = { - installed: LocalServerInstalledDistro[] - online: LocalServerOnlineDistro[] - selected: LocalServerDistroProbe | null - error: string | null -} -export type LocalServerOpencodeCheck = { - distro: string | null +export type WslOpencodeCheck = { + distro: string resolvedPath: string | null version: string | null expectedVersion: string | null matchesDesktop: boolean | null error: string | null } -export type LocalServerTranscriptLine = { +export type WslTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string at: number } -export type LocalServerConfig = { - mode: LocalServerMode - distro: string | null - onboarding: { - step: LocalServerStep | null - complete: boolean - pendingRestart: boolean - } - acknowledgements: { - root: string[] - mismatch: LocalServerMismatchAcknowledgement[] - } + +export type WslServerAcknowledgements = { + root: boolean + mismatch: { path: string; version: string } | null } -export type LocalServerStatus = - | { kind: "idle" } - | { kind: "ready" } - | { kind: "running"; step: LocalServerStep | null } - | { kind: "failed"; step: LocalServerStep | null; message: string } -export type LocalServerState = { - config: LocalServerConfig - runtime: { - key: string - mode: LocalServerMode - distro: string | null - } - status: LocalServerStatus - job: { step: LocalServerStep | null; startedAt: number } | null - checks: { - wsl: LocalServerWslCheck | null - distro: LocalServerDistroCheck | null - opencode: LocalServerOpencodeCheck | null - } - transcript: LocalServerTranscriptLine[] + +export type WslServerConfig = { + id: string + distro: string + acknowledgements: WslServerAcknowledgements } -export type LocalServerEvent = { - type: "state" - state: LocalServerState + +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 LocalServerPlatform = { - getState(): Promise - setConfig(config: LocalServerConfig): Promise - runStep(step: LocalServerStep): Promise - cancelJob(): Promise + +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 + opencodeChecks: Record + pendingRestart: boolean + servers: WslServerItem[] + job: WslJob | null + transcript: WslTranscriptLine[] + lastError: string | null +} +export type WslServersEvent = { type: "state"; state: WslServersState } + +export type WslServersPlatform = { + getState(): Promise + subscribe(cb: (event: WslServersEvent) => void): () => void + probeRuntime(): Promise + refreshDistros(): Promise installWsl(): Promise installDistro(name: string): Promise - installOpencode(): Promise - openTerminal(): Promise - subscribe(cb: (event: LocalServerEvent) => void): () => void + probeDistro(name: string): Promise + probeOpencode(name: string): Promise + installOpencode(name: string): Promise + openTerminal(name: string): Promise + addServer(distro: string): Promise + removeServer(id: string): Promise + startServer(id: string): Promise + stopServer(id: string): Promise + cancelJob(): Promise + updateAcknowledgements(id: string, acks: Partial): Promise } export type Platform = { @@ -164,14 +169,8 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void - /** Manage the desktop Local Server lifecycle (desktop only) */ - localServer?: LocalServerPlatform - - /** Get the configured WSL integration (desktop only) */ - getWslEnabled?(): Promise - - /** Set the configured WSL integration (desktop only) */ - setWslEnabled?(config: boolean): Promise | void + /** Manage WSL sidecar servers (Electron on Windows only) */ + wslServers?: WslServersPlatform /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5c86c9f4bed2..f68031548616 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -214,6 +214,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( onCleanup(startHealthPolling(current_)) }) + createEffect(() => { + const key = state.active + if (typeof window === "undefined") return + window.__OPENCODE__ ??= {} + window.__OPENCODE__.activeServer = key + }) + const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const current: Accessor = createMemo( diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 969732dda658..4173cf9ca7be 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,19 +1,21 @@ export { AppBaseProviders, AppInterface } from "./app" -export { DialogLocalServer } from "./components/dialog-local-server" +export { DialogWslServer } from "./components/dialog-wsl-server" 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 LocalServerConfig, - type LocalServerEvent, - type LocalServerMode, - type LocalServerOpencodeCheck, - type LocalServerPlatform, - type LocalServerState, - type LocalServerStep, type Platform, PlatformProvider, + type WslInstalledDistro, + type WslOnlineDistro, + type WslOpencodeCheck, + type WslServerConfig, + type WslServerItem, + type WslServersEvent, + type WslServersPlatform, + type WslServersState, + type WslServerStep, } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 4619a336aeae..9a6bb53c64fd 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -6,5 +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 LOCAL_SERVER_KEY = "localServer" +export const WSL_SERVERS_KEY = "wslServers" +export const LEGACY_LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 5d09a8f64bd9..84720b30303d 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" import { existsSync } from "node:fs" -import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" import type { Event } from "electron" @@ -36,14 +35,14 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, LOCAL_SERVER_KEY, UPDATER_ENABLED } from "./constants" +import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" -import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server" +import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" import { store } from "./store" +import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" const initEmitter = new EventEmitter() @@ -57,7 +56,13 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() void serverReady.promise.catch(() => undefined) -const localServer = createLocalServerController(app.getVersion()) +const wslServers = createWslServersController(app.getVersion(), async (distro) => { + const logger = initLogging() + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) +}) const logger = initLogging() logger.log("app starting", { @@ -67,8 +72,8 @@ logger.log("app starting", { logger.log("config paths", { userData: app.getPath("userData"), settingsStore: store.path, - localServerKey: LOCAL_SERVER_KEY, - localServer: store.get(LOCAL_SERVER_KEY) ?? null, + wslServersKey: WSL_SERVERS_KEY, + wslServers: store.get(WSL_SERVERS_KEY) ?? null, }) setupApp() @@ -107,15 +112,18 @@ function setupApp() { app.on("before-quit", () => { killSidecar() + wslServers.stopAll() }) app.on("will-quit", () => { killSidecar() + wslServers.stopAll() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { killSidecar() + wslServers.stopAll() app.exit(0) }) } @@ -151,57 +159,39 @@ async function initialize() { const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null - const port = await getSidecarPort() + const port = await allocatePort() const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() - const config = localServer.getState().config - const runtime = - config.mode === "wsl" && config.distro - ? { - key: `local:wsl:${config.distro}`, - mode: "wsl" as const, - distro: config.distro, - } - : { - key: "local:windows", - mode: "windows" as const, - distro: null, - } - - logger.log("spawning sidecar", { url }) - localServer.setRuntime(runtime) - localServer.setStatus({ kind: "running", step: null }) - const startupData = { + const key = "local:windows" + + logger.log("spawning windows sidecar", { url }) + const startupData: ServerReadyData = { url, username: "opencode", password, - local: runtime, + local: { + key, + url, + username: "opencode", + password, + }, } let startupError: Error | null = null const startup = await (async () => { try { - if (runtime.mode === "wsl") { - if (!runtime.distro) throw new Error("No WSL distro selected") - return await spawnWslLocalServer(runtime.distro, port, password, { - onLine: (line) => - logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }), - }) - } return await spawnLocalServer(hostname, port, password) } catch (error) { startupError = asError(error) - localServer.setStatus({ - kind: "failed", - step: null, - message: startupError.message, - }) - logger.error("local server startup failed", startupError) + logger.error("windows sidecar startup failed", startupError) return undefined } })() server = startup?.listener ?? null + // Initialize WSL sidecars in parallel; failures do not block app startup. + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) + const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -224,16 +214,10 @@ async function initialize() { }), ]) .then(() => { - localServer.setStatus({ kind: "ready" }) serverReady.resolve(startupData) }) .catch((error) => { startupError = asError(error) - localServer.setStatus({ - kind: "failed", - step: null, - message: startupError.message, - }) logger.error("sidecar health check failed", startupError) serverReady.reject(startupError) }) @@ -321,6 +305,7 @@ function wireMenu() { reload: () => mainWindow?.reload(), relaunch: () => { killSidecar() + wslServers.stopAll() app.relaunch() app.exit(0) }, @@ -342,15 +327,22 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, - getLocalServerState: () => localServer.getState(), - setLocalServerConfig: (config) => localServer.setConfig(config), - runLocalServerStep: (step) => localServer.runStep(step), - cancelLocalServerJob: () => localServer.cancelJob(), - installLocalServerWsl: () => localServer.installWsl(), - installLocalServerDistro: (name) => localServer.installDistro(name), - installLocalServerOpencode: () => localServer.installOpencode(), - openLocalServerTerminal: () => localServer.openTerminal(), - onLocalServerEvent: (listener) => localServer.subscribe(listener), + getWslServersState: () => wslServers.getState(), + onWslServersEvent: (listener) => wslServers.subscribe(listener), + wslServersProbeRuntime: () => wslServers.probeRuntime(), + wslServersRefreshDistros: () => wslServers.refreshDistros(), + wslServersInstallWsl: () => wslServers.installWsl(), + wslServersInstallDistro: (name) => wslServers.installDistro(name), + wslServersProbeDistro: (name) => wslServers.probeDistro(name), + wslServersProbeOpencode: (name) => wslServers.probeOpencode(name), + wslServersInstallOpencode: (name) => wslServers.installOpencode(name), + wslServersOpenTerminal: (name) => wslServers.openTerminal(name), + wslServersAddServer: (distro) => wslServers.addServer(distro), + wslServersRemoveServer: (id) => wslServers.removeServer(id), + wslServersStartServer: (id) => wslServers.startServer(id), + wslServersStopServer: (id) => wslServers.stopServer(id), + wslServersCancelJob: () => wslServers.cancelJob(), + wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), getDisplayBackend: async () => null, @@ -392,29 +384,6 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - return await new Promise((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 - } - const port = address.port - server.close(() => resolve(port)) - }) - }) -} - function sqliteFileExists() { const xdg = process.env.XDG_DATA_HOME const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") @@ -478,6 +447,7 @@ async function checkUpdate() { async function installUpdate() { if (!updateReady) return killSidecar() + wslServers.stopAll() autoUpdater.quitAndInstall() } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8c325bb6d663..5134866ba42b 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -4,13 +4,13 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { InitStep, - LocalServerConfig, - LocalServerEvent, - LocalServerState, - LocalServerStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, + WslServerAcknowledgements, + WslServerConfig, + WslServersEvent, + WslServersState, } from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -23,15 +23,22 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise - getLocalServerState: () => Promise | LocalServerState - setLocalServerConfig: (config: LocalServerConfig) => Promise | void - runLocalServerStep: (step: LocalServerStep) => Promise | void - cancelLocalServerJob: () => Promise | void - installLocalServerWsl: () => Promise | void - installLocalServerDistro: (name: string) => Promise | void - installLocalServerOpencode: () => Promise | void - openLocalServerTerminal: () => Promise | void - onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void + getWslServersState: () => Promise | WslServersState + onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void + wslServersProbeRuntime: () => Promise | void + wslServersRefreshDistros: () => Promise | void + wslServersInstallWsl: () => Promise | void + wslServersInstallDistro: (name: string) => Promise | void + wslServersProbeDistro: (name: string) => Promise | void + wslServersProbeOpencode: (name: string) => Promise | void + wslServersInstallOpencode: (name: string) => Promise | void + wslServersOpenTerminal: (name: string) => Promise | void + wslServersAddServer: (distro: string) => Promise | WslServerConfig + wslServersRemoveServer: (id: string) => Promise | void + wslServersStartServer: (id: string) => Promise | void + wslServersStopServer: (id: string) => Promise | void + wslServersCancelJob: () => Promise | void + wslServersUpdateAcknowledgements: (id: string, acks: Partial) => Promise | void getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void getDisplayBackend: () => Promise @@ -48,33 +55,48 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { - const offLocalServer = deps.onLocalServerEvent((payload) => { + const offWslServers = deps.onWslServersEvent((payload) => { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue - win.webContents.send("local-server-event", payload) + win.webContents.send("wsl-servers-event", payload) } }) - app.once("will-quit", offLocalServer) + app.once("will-quit", offWslServers) ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) - ipcMain.handle("local-server-get-state", () => deps.getLocalServerState()) - ipcMain.handle("local-server-set-config", (_event: IpcMainInvokeEvent, config: LocalServerConfig) => - deps.setLocalServerConfig(config), + ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) + ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) + ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) + ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) + ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallDistro(name), ) - ipcMain.handle("local-server-run-step", (_event: IpcMainInvokeEvent, step: LocalServerStep) => - deps.runLocalServerStep(step), + ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeDistro(name), ) - ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob()) - ipcMain.handle("local-server-install-wsl", () => deps.installLocalServerWsl()) - ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) => - deps.installLocalServerDistro(name), + ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeOpencode(name), + ) + ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallOpencode(name), + ) + ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersOpenTerminal(name), + ) + ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro)) + ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id)) + ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id)) + ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id)) + ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) + ipcMain.handle( + "wsl-servers-update-acknowledgements", + (_event: IpcMainInvokeEvent, id: string, acks: Partial) => + deps.wslServersUpdateAcknowledgements(id, acks), ) - ipcMain.handle("local-server-install-opencode", () => deps.installLocalServerOpencode()) - ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts deleted file mode 100644 index f414014d2be8..000000000000 --- a/packages/desktop-electron/src/main/local-server.ts +++ /dev/null @@ -1,678 +0,0 @@ -import type { - LocalServerConfig, - LocalServerDistroCheck, - LocalServerEvent, - LocalServerOpencodeCheck, - LocalServerState, - LocalServerStep, - LocalServerTranscriptLine, -} from "../preload/types" -import { LOCAL_SERVER_KEY } from "./constants" -import { store } from "./store" -import { - installWslDistro, - installWslOpencode, - installWslRuntimeElevated, - listInstalledWslDistros, - listOnlineWslDistros, - openWslTerminal, - probeWslDistro, - probeWslRuntime, - readWslCommandVersion, - resolveWslOpencode, - upgradeWslOpencode, - wslNeedsRestart, -} from "./wsl" - -export function defaultLocalServerConfig(): LocalServerConfig { - return { - mode: "windows", - distro: null, - onboarding: { - step: null, - complete: true, - pendingRestart: false, - }, - acknowledgements: { - root: [], - mismatch: [], - }, - } -} - -export function createLocalServerController(appVersion: string) { - let state = toState(readLocalServerConfig()) - const listeners = new Set<(event: LocalServerEvent) => void>() - let jobAbort: AbortController | undefined - - const emit = (event: LocalServerEvent) => { - for (const listener of listeners) listener(event) - } - - const update = (next: LocalServerState) => { - state = next - emit({ type: "state", state }) - } - - const appendTranscript = (line: Omit) => { - update({ - ...state, - transcript: [...state.transcript, { ...line, at: Date.now() }], - }) - } - - const clearTranscript = () => { - update({ - ...state, - transcript: [], - }) - } - - const persistConfig = (config: LocalServerConfig) => { - const next = normalizeLocalServerConfig(config) - store.set(LOCAL_SERVER_KEY, next) - update({ - ...state, - config: next, - }) - return next - } - - return { - getState() { - return state - }, - setConfig(config: LocalServerConfig) { - persistConfig(config) - }, - subscribe(listener: (event: LocalServerEvent) => void) { - listeners.add(listener) - return () => listeners.delete(listener) - }, - async runStep(step: LocalServerStep) { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: `Running local server step: ${step}` }) - update({ - ...state, - job: { step, startedAt: Date.now() }, - status: { kind: "running", step }, - }) - - try { - if (step === "wsl") { - const wsl = await probeWslRuntime({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - update({ - ...state, - job: null, - status: wsl.available - ? { kind: "ready" } - : { kind: "failed", step, message: wsl.error ?? "WSL is unavailable" }, - checks: { - ...state.checks, - wsl, - }, - }) - return - } - - if (step === "distro") { - const [installedResult, onlineResult] = await Promise.allSettled([ - listInstalledWslDistros({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }), - listOnlineWslDistros({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }), - ]) - if (jobAbort !== abort) return - - const installed = installedResult.status === "fulfilled" ? installedResult.value : [] - const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] - const selected = state.config.distro - ? await probeWslDistro(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const error = distroError(state.config.distro, installed, selected, installedResult, onlineResult) - const distro: LocalServerDistroCheck = { - installed, - online, - selected, - error, - } - - update({ - ...state, - job: null, - status: error ? { kind: "failed", step, message: error } : { kind: "ready" }, - checks: { - ...state.checks, - distro, - }, - }) - return - } - - if (step === "opencode") { - if (!state.config.distro) { - update({ - ...state, - job: null, - status: { kind: "failed", step, message: "No WSL distro selected" }, - }) - return - } - - const resolvedPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const version = resolvedPath - ? await readWslCommandVersion(resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const opencode = opencodeCheck(resolvedPath, version, appVersion, state.config.distro) - update({ - ...state, - job: null, - status: opencode.error ? { kind: "failed", step, message: opencode.error } : { kind: "ready" }, - checks: { - ...state.checks, - opencode, - }, - }) - return - } - - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - } catch (error) { - if (jobAbort !== abort) return - if (error instanceof Error && error.name === "AbortError") { - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - return - } - update({ - ...state, - job: null, - status: { kind: "failed", step, message: error instanceof Error ? error.message : String(error) }, - }) - } finally { - if (jobAbort === abort) jobAbort = undefined - } - }, - cancelJob() { - jobAbort?.abort() - jobAbort = undefined - appendTranscript({ stream: "system", text: "Canceled local server job" }) - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - }, - async installWsl() { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: "Installing WSL runtime" }) - persistConfig({ - ...state.config, - mode: "wsl", - onboarding: { - ...state.config.onboarding, - step: "wsl", - complete: false, - pendingRestart: false, - }, - }) - update({ - ...state, - job: { step: "wsl", startedAt: Date.now() }, - status: { kind: "running", step: "wsl" }, - }) - - try { - const result = await installWslRuntimeElevated({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - if (result.code !== 0) throw new Error(commandFailure(result, "WSL installation failed")) - - const pendingRestart = wslNeedsRestart(result) - const nextConfig = persistConfig({ - ...state.config, - mode: "wsl", - onboarding: { - ...state.config.onboarding, - step: pendingRestart ? "wsl" : "distro", - complete: false, - pendingRestart, - }, - }) - - if (pendingRestart) { - const message = "Windows restart required to finish WSL installation" - update({ - ...state, - config: nextConfig, - job: null, - status: { kind: "failed", step: "wsl", message }, - checks: { - ...state.checks, - wsl: { - available: false, - version: null, - status: null, - error: message, - }, - }, - }) - return - } - - const wsl = await probeWslRuntime({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - update({ - ...state, - config: nextConfig, - job: null, - status: wsl.available - ? { kind: "ready" } - : { kind: "failed", step: "wsl", message: wsl.error ?? "WSL is unavailable" }, - checks: { - ...state.checks, - wsl, - }, - }) - } catch (error) { - if (jobAbort !== abort) return - if (error instanceof Error && error.name === "AbortError") { - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - return - } - update({ - ...state, - job: null, - status: { kind: "failed", step: "wsl", message: error instanceof Error ? error.message : String(error) }, - }) - } finally { - if (jobAbort === abort) jobAbort = undefined - } - }, - async installDistro(name: string) { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) - persistConfig({ - ...state.config, - mode: "wsl", - distro: name, - onboarding: { - ...state.config.onboarding, - step: "distro", - complete: false, - pendingRestart: false, - }, - }) - update({ - ...state, - job: { step: "distro", startedAt: Date.now() }, - status: { kind: "running", step: "distro" }, - }) - - try { - const result = await installWslDistro(name, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - if (result.code !== 0) throw new Error(commandFailure(result, `Failed to install distro: ${name}`)) - - const [installedResult, onlineResult] = await Promise.allSettled([ - listInstalledWslDistros({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }), - listOnlineWslDistros({ - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }), - ]) - if (jobAbort !== abort) return - - const installed = installedResult.status === "fulfilled" ? installedResult.value : [] - const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] - const selected = await probeWslDistro(name, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - - const error = distroError(name, installed, selected, installedResult, onlineResult) - const nextConfig = persistConfig({ - ...state.config, - mode: "wsl", - distro: name, - onboarding: { - ...state.config.onboarding, - step: error ? "distro" : "opencode", - complete: false, - pendingRestart: false, - }, - }) - update({ - ...state, - config: nextConfig, - job: null, - status: error ? { kind: "failed", step: "distro", message: error } : { kind: "ready" }, - checks: { - ...state.checks, - distro: { - installed, - online, - selected, - error, - }, - }, - }) - } catch (error) { - if (jobAbort !== abort) return - if (error instanceof Error && error.name === "AbortError") { - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - return - } - update({ - ...state, - job: null, - status: { kind: "failed", step: "distro", message: error instanceof Error ? error.message : String(error) }, - }) - } finally { - if (jobAbort === abort) jobAbort = undefined - } - }, - async installOpencode() { - if (!state.config.distro) throw new Error("No WSL distro selected") - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - clearTranscript() - appendTranscript({ stream: "system", text: `Installing OpenCode in ${state.config.distro}` }) - update({ - ...state, - job: { step: "opencode", startedAt: Date.now() }, - status: { kind: "running", step: "opencode" }, - }) - - try { - const resolvedPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const currentVersion = resolvedPath - ? await readWslCommandVersion(resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const result = - resolvedPath && currentVersion - ? await upgradeWslOpencode(appVersion, resolvedPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : await installWslOpencode(appVersion, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - if (result.code !== 0) throw new Error(commandFailure(result, "OpenCode installation failed")) - - const nextPath = await resolveWslOpencode(state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - if (jobAbort !== abort) return - const nextVersion = nextPath - ? await readWslCommandVersion(nextPath, state.config.distro, { - signal: abort.signal, - onLine: (line) => appendTranscript(line), - }) - : null - if (jobAbort !== abort) return - - const opencode = opencodeCheck(nextPath, nextVersion, appVersion, state.config.distro) - update({ - ...state, - job: null, - status: opencode.error ? { kind: "failed", step: "opencode", message: opencode.error } : { kind: "ready" }, - checks: { - ...state.checks, - opencode, - }, - }) - } catch (error) { - if (jobAbort !== abort) return - if (error instanceof Error && error.name === "AbortError") { - update({ - ...state, - job: null, - status: { kind: "idle" }, - }) - return - } - update({ - ...state, - job: null, - status: { kind: "failed", step: "opencode", message: error instanceof Error ? error.message : String(error) }, - }) - } finally { - if (jobAbort === abort) jobAbort = undefined - } - }, - async openTerminal() { - if (!state.config.distro) throw new Error("No WSL distro selected") - await openWslTerminal(state.config.distro) - }, - setRuntime(runtime: LocalServerState["runtime"]) { - update({ - ...state, - runtime, - }) - }, - setStatus(status: LocalServerState["status"]) { - update({ - ...state, - status, - }) - }, - } -} - -function readLocalServerConfig() { - return normalizeLocalServerConfig(store.get(LOCAL_SERVER_KEY)) -} - -function toState(config: LocalServerConfig, current?: LocalServerState): LocalServerState { - return { - config, - runtime: current?.runtime ?? windowsRuntime(), - status: current?.status ?? { kind: "idle" }, - job: current?.job ?? null, - checks: current?.checks ?? { wsl: null, distro: null, opencode: null }, - transcript: current?.transcript ?? [], - } -} - -function normalizeLocalServerConfig(value: unknown): LocalServerConfig { - const fallback = defaultLocalServerConfig() - if (!value || typeof value !== "object") return fallback - const record = value as Record - const mode = record.mode === "wsl" ? "wsl" : "windows" - const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null - return { - mode, - distro, - onboarding: normalizeOnboarding(record.onboarding, mode), - acknowledgements: normalizeAcknowledgements(record.acknowledgements), - } -} - -function normalizeOnboarding(value: unknown, mode: LocalServerConfig["mode"]): LocalServerConfig["onboarding"] { - const record = value && typeof value === "object" ? (value as Record) : {} - return { - step: normalizeStep(record.step), - complete: typeof record.complete === "boolean" ? record.complete : mode === "windows", - pendingRestart: typeof record.pendingRestart === "boolean" ? record.pendingRestart : false, - } -} - -function normalizeAcknowledgements(value: unknown): LocalServerConfig["acknowledgements"] { - const record = value && typeof value === "object" ? (value as Record) : {} - return { - root: Array.isArray(record.root) - ? record.root.filter((item): item is string => typeof item === "string" && item.length > 0) - : [], - mismatch: Array.isArray(record.mismatch) - ? record.mismatch.flatMap((item) => { - if (!item || typeof item !== "object") return [] - const path = typeof item.path === "string" ? item.path : "" - const version = typeof item.version === "string" ? item.version : "" - if (!path || !version) return [] - return [{ path, version }] - }) - : [], - } -} - -function normalizeStep(value: unknown): LocalServerStep | null { - if (value === "wsl") return value - if (value === "distro") return value - if (value === "opencode") return value - if (value === "switch") return value - return null -} - -function localServerKey(config: LocalServerConfig) { - if (config.mode === "windows") return "local:windows" - if (!config.distro) return "local:wsl" - return `local:wsl:${config.distro}` -} - -function windowsRuntime(): LocalServerState["runtime"] { - return { - key: localServerKey({ - ...defaultLocalServerConfig(), - mode: "windows", - }), - mode: "windows", - distro: null, - } -} - -function distroError( - configured: string | null, - installed: LocalServerDistroCheck["installed"], - selected: LocalServerDistroCheck["selected"], - installedResult: PromiseSettledResult, - onlineResult: PromiseSettledResult, -) { - if (installedResult.status === "rejected") { - return installedResult.reason instanceof Error ? installedResult.reason.message : String(installedResult.reason) - } - if (onlineResult.status === "rejected") { - return onlineResult.reason instanceof Error ? onlineResult.reason.message : String(onlineResult.reason) - } - if (configured && !installed.find((item) => item.name === configured)) { - return `Selected distro is not installed: ${configured}` - } - if (selected?.error) return selected.error - return null -} - -function commandFailure(result: { stdout: string; stderr: string }, fallback: string) { - const output = `${result.stderr}\n${result.stdout}` - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - .join("\n") - return output || fallback -} - -function opencodeCheck( - resolvedPath: string | null, - version: string | null, - expectedVersion: string, - distro: string | null, -): LocalServerOpencodeCheck { - if (!resolvedPath) { - return { - distro, - resolvedPath: null, - version: null, - expectedVersion, - matchesDesktop: null, - error: "opencode is not installed in the selected distro", - } - } - if (!version) { - return { - distro, - resolvedPath, - version: null, - expectedVersion, - matchesDesktop: null, - error: "opencode is installed but could not run in the selected distro", - } - } - return { - distro, - resolvedPath, - version, - expectedVersion, - matchesDesktop: version ? version === expectedVersion : null, - error: null, - } -} diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 7ccb33b2f204..da856cf55840 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import { createServer } from "node:net" import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" @@ -21,6 +23,28 @@ export function setDefaultServerUrl(url: string | null) { getStore().delete(DEFAULT_SERVER_URL_KEY) } +export async function allocatePort() { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + return new Promise((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 + } + const port = address.port + server.close(() => resolve(port)) + }) + }) +} + export async function spawnLocalServer(hostname: string, port: number, password: string) { prepareServerEnv(password) const { Log, Server } = await import("virtual:opencode-server") @@ -48,21 +72,30 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } -export async function spawnWslLocalServer( +export type WslSidecar = { + listener: { stop: () => void } + url: string + username: string | null + password: string +} + +export async function spawnWslSidecar( distro: string, - port: number, - password: string, - opts: { onLine?: (line: WslCommandLine) => void } = {}, -) { + opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, +): Promise { 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", "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true", "export OPENCODE_CLIENT=desktop", - `export OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`, + `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}`, @@ -93,26 +126,41 @@ export async function spawnWslLocalServer( }) }) - const wait = Promise.race([ - (async () => { - const url = `http://127.0.0.1:${port}` - while (true) { - await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return - } - })(), - exit, - ]).finally(() => { - settled = true + const url = `http://127.0.0.1:${port}` + const healthPromise = (async () => { + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (await checkHealth(url, password)) return + } + })() + + const timeoutMs = opts.healthTimeoutMs ?? 30_000 + const timeout = new Promise((_, reject) => { + const id = setTimeout( + () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + void healthPromise.finally(() => clearTimeout(id)) }) + try { + await Promise.race([healthPromise, exit, timeout]) + } catch (error) { + child.kill() + throw error + } finally { + settled = true + } + return { listener: { stop() { child.kill() }, }, - health: { wait }, + url, + username, + password, } } @@ -156,7 +204,7 @@ function forwardLines( function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" - return `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` + return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` } export async function checkHealth(url: string, password?: string | null): Promise { diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts new file mode 100644 index 000000000000..5e2438aa09d9 --- /dev/null +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -0,0 +1,483 @@ +import type { + WslDistroProbe, + WslInstalledDistro, + WslJob, + WslOnlineDistro, + WslOpencodeCheck, + WslRuntimeCheck, + WslServerAcknowledgements, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, + WslTranscriptLine, +} from "../preload/types" +import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" +import { spawnWslSidecar } from "./server" +import { store } from "./store" +import type { WslCommandLine } from "./wsl" +import { + installWslDistro, + installWslOpencode, + installWslRuntimeElevated, + listInstalledWslDistros, + listOnlineWslDistros, + openWslTerminal, + probeWslDistro, + probeWslRuntime, + readWslCommandVersion, + resolveWslOpencode, + upgradeWslOpencode, + wslNeedsRestart, +} from "./wsl" + +type RunningSidecar = { + listener: { stop: () => void } + url: string + username: string | null + password: string +} + +type SpawnSidecar = (distro: string) => Promise + +export type WslServersController = ReturnType + +export function wslServerIdForDistro(distro: string) { + return `wsl:${distro}` +} + +export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar) { + let state: WslServersState = initialState() + const listeners = new Set<(event: WslServersEvent) => void>() + const sidecars = new Map() + let jobAbort: AbortController | undefined + + const emit = () => { + for (const listener of listeners) listener({ type: "state", state }) + } + + const setState = (next: Partial) => { + state = { ...state, ...next } + emit() + } + + const appendTranscript = (line: Omit) => { + setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] }) + } + + const clearTranscript = () => setState({ transcript: [] }) + + const persistServers = (servers: WslServerConfig[]) => { + store.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, opts: { keepTranscript?: boolean } = {}): AbortController => { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + if (!opts.keepTranscript) clearTranscript() + setState({ job, lastError: null }) + return abort + } + + const endJob = (abort: AbortController, error?: Error | null) => { + if (jobAbort !== abort) return + jobAbort = undefined + setState({ job: null, lastError: error?.message ?? null }) + } + + const onLine = (line: WslCommandLine) => appendTranscript(line) + + 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 startServer = async (id: string) => { + const item = state.servers.find((x) => x.config.id === id) + if (!item) return + await stopServerInternal(id) + setRuntime(id, { kind: "starting" }) + try { + const sidecar = await spawnSidecar(item.config.distro) + sidecars.set(id, sidecar) + setRuntime(id, { + kind: "ready", + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + } catch (error) { + setRuntime(id, { + kind: "failed", + message: error instanceof Error ? error.message : String(error), + }) + } + } + + const stopServerInternal = async (id: string) => { + const existing = sidecars.get(id) + if (!existing) return + try { + existing.listener.stop() + } catch { + // ignore stop errors + } + sidecars.delete(id) + } + + const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { + 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, err) + throw err + } + } + + return { + getState() { + return state + }, + subscribe(listener: (event: WslServersEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + async initialize() { + refreshFromStore() + await Promise.all(state.servers.map((item) => startServer(item.config.id))) + }, + + async probeRuntime() { + await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Checking WSL runtime" }) + const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) + setState({ + runtime, + pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, + }) + }) + }, + + async refreshDistros() { + await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Listing WSL distros" }) + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal, onLine }), + listOnlineWslDistros({ signal: abort.signal, onLine }), + ]) + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + setState({ installed, online }) + }) + }, + + async installWsl() { + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: "Installing WSL runtime" }) + const result = await installWslRuntimeElevated({ signal: abort.signal, onLine }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || "WSL installation failed" + throw new Error(message) + } + const pendingRestart = wslNeedsRestart(result) + setState({ pendingRestart }) + if (!pendingRestart) { + const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) + setState({ runtime }) + } + }) + }, + + async installDistro(name: string) { + await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) + const result = await installWslDistro(name, { signal: abort.signal, onLine }) + if (result.code !== 0) { + const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` + throw new Error(message) + } + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros({ signal: abort.signal, onLine }), + listOnlineWslDistros({ signal: abort.signal, onLine }), + ]) + const installed = installedResult.status === "fulfilled" ? installedResult.value : [] + const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) + setState({ + installed, + online, + distroProbes: { ...state.distroProbes, [name]: probe }, + }) + }) + }, + + async probeDistro(name: string) { + await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Checking ${name}` }) + const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) + setState({ distroProbes: { ...state.distroProbes, [name]: probe } }) + }) + }, + + 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), + }, + }) + }) + }, + + async installOpencode(name: string) { + await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { + appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` }) + const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine }) + const existingVersion = resolved + ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) + : null + const result = + resolved && existingVersion + ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine }) + : await installWslOpencode(appVersion, name, { signal: abort.signal, onLine }) + 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), + }, + }) + }) + }, + + async openTerminal(name: string) { + await openWslTerminal(name) + }, + + async cancelJob() { + jobAbort?.abort() + jobAbort = undefined + appendTranscript({ stream: "system", text: "Canceled" }) + setState({ job: null }) + }, + + async addServer(distro: string): Promise { + 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, + acknowledgements: { root: false, mismatch: null }, + } + persistServers([...readPersistedServers(), config]) + setState({ + servers: [...state.servers, { config, runtime: { kind: "starting" } }], + }) + void startServer(id) + return config + }, + + async removeServer(id: string) { + await stopServerInternal(id) + const remaining = readPersistedServers().filter((item) => item.id !== id) + persistServers(remaining) + setState({ servers: state.servers.filter((item) => item.config.id !== id) }) + }, + + startServer, + + async stopServer(id: string) { + await stopServerInternal(id) + setRuntime(id, { kind: "stopped" }) + }, + + async updateAcknowledgements(id: string, acks: Partial) { + const persisted = readPersistedServers() + const next = persisted.map((config) => + config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config, + ) + persistServers(next) + refreshFromStore() + }, + + stopAll() { + for (const [id] of sidecars) { + const existing = sidecars.get(id) + try { + existing?.listener.stop() + } catch { + // ignore + } + } + sidecars.clear() + }, + } +} + +function initialState(): WslServersState { + return { + runtime: null, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + transcript: [], + lastError: null, + } +} + +function readPersistedServers(): WslServerConfig[] { + 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) + } + const migrated = migrateLegacyLocalServer() + if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) + return migrated +} + +function migrateLegacyLocalServer(): WslServerConfig[] { + const legacy = store.get(LEGACY_LOCAL_SERVER_KEY) + if (!legacy || typeof legacy !== "object") return [] + const record = legacy as Record + if (record.mode !== "wsl") return [] + const distro = typeof record.distro === "string" ? record.distro : null + if (!distro) return [] + return [ + { + id: wslServerIdForDistro(distro), + distro, + acknowledgements: { root: false, mismatch: null }, + }, + ] +} + +function normalizePersistedServer(value: unknown): WslServerConfig[] { + if (!value || typeof value !== "object") return [] + const record = value as Record + 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, + acknowledgements: normalizeAcks(record.acknowledgements), + }, + ] +} + +function normalizeAcks(value: unknown): WslServerAcknowledgements { + const record = value && typeof value === "object" ? (value as Record) : {} + const mismatch = + record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record) : null + return { + root: record.root === true, + mismatch: + mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string" + ? { path: mismatch.path, version: mismatch.version } + : null, + } +} + +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 summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +// Re-export types used by callers +export type { + WslInstalledDistro, + WslOnlineDistro, + WslRuntimeCheck, + WslDistroProbe, + WslOpencodeCheck, + WslServerConfig, + WslServerItem, + WslServerRuntime, + WslServersEvent, + WslServersState, +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index c11a2bef9d77..6f5c2bca17ae 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,10 +1,5 @@ import { spawn } from "node:child_process" -import type { - LocalServerDistroProbe, - LocalServerInstalledDistro, - LocalServerOnlineDistro, - LocalServerWslCheck, -} from "../preload/types" +import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -135,7 +130,7 @@ export function runWslBash(script: string, distro?: string | null, opts?: RunWsl return runWslInDistro(["bash", "-lc", script], distro, opts) } -export async function probeWslRuntime(opts?: RunWslOptions): Promise { +export async function probeWslRuntime(opts?: RunWslOptions): Promise { const version = await runWsl(["--version"], opts).catch((error) => ({ code: 1, signal: null, @@ -206,7 +201,7 @@ export function wslNeedsRestart(result: WslCommandResult) { return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) } -export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { +export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({ code: 1, signal: null, @@ -298,7 +293,7 @@ function parseInstalledDistros(output: string) { state: state || null, version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), isDefault: marker === "*", - } satisfies LocalServerInstalledDistro, + } satisfies WslInstalledDistro, ] }) } @@ -311,7 +306,7 @@ function parseOnlineDistros(output: string) { if (!match) return [] const [, name, label] = match if (/^name$/i.test(name)) return [] - return [{ name, label: label.trim() } satisfies LocalServerOnlineDistro] + return [{ name, label: label.trim() } satisfies WslOnlineDistro] }) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 3d7db1e6d9a4..faf0d692cb43 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI, InitStep, LocalServerEvent, SqliteMigrationProgress } from "./types" +import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), @@ -11,20 +11,27 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, - localServer: { - getState: () => ipcRenderer.invoke("local-server-get-state"), - setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config), - runStep: (step) => ipcRenderer.invoke("local-server-run-step", step), - cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), - installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), - installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), - installOpencode: () => ipcRenderer.invoke("local-server-install-opencode"), - openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), + wslServers: { + getState: () => ipcRenderer.invoke("wsl-servers-get-state"), subscribe: (cb) => { - const handler = (_: unknown, event: LocalServerEvent) => cb(event) - ipcRenderer.on("local-server-event", handler) - return () => ipcRenderer.removeListener("local-server-event", handler) + const handler = (_: unknown, event: WslServersEvent) => cb(event) + ipcRenderer.on("wsl-servers-event", handler) + return () => ipcRenderer.removeListener("wsl-servers-event", handler) }, + 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), + stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id), + cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"), + updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks), }, getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 78998e747195..18183868ad32 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -6,36 +6,33 @@ export type ServerReadyData = { password: string | null local: { key: string - mode: LocalServerMode - distro: string | null + url: string + username: string | null + password: string | null } } export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type LocalServerMode = "windows" | "wsl" -export type LocalServerStep = "wsl" | "distro" | "opencode" | "switch" -export type LocalServerMismatchAcknowledgement = { - path: string - version: string -} -export type LocalServerWslCheck = { +export type WslServerStep = "wsl" | "distro" | "opencode" + +export type WslRuntimeCheck = { available: boolean version: string | null status: string | null error: string | null } -export type LocalServerInstalledDistro = { +export type WslInstalledDistro = { name: string state: string | null version: number | null isDefault: boolean } -export type LocalServerOnlineDistro = { +export type WslOnlineDistro = { name: string label: string } -export type LocalServerDistroProbe = { +export type WslDistroProbe = { name: string canExecute: boolean hasBash: boolean @@ -44,73 +41,82 @@ export type LocalServerDistroProbe = { isRoot: boolean | null error: string | null } -export type LocalServerDistroCheck = { - installed: LocalServerInstalledDistro[] - online: LocalServerOnlineDistro[] - selected: LocalServerDistroProbe | null - error: string | null -} -export type LocalServerOpencodeCheck = { - distro: string | null +export type WslOpencodeCheck = { + distro: string resolvedPath: string | null version: string | null expectedVersion: string | null matchesDesktop: boolean | null error: string | null } -export type LocalServerTranscriptLine = { +export type WslTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string at: number } -export type LocalServerConfig = { - mode: LocalServerMode - distro: string | null - onboarding: { - step: LocalServerStep | null - complete: boolean - pendingRestart: boolean - } - acknowledgements: { - root: string[] - mismatch: LocalServerMismatchAcknowledgement[] - } + +export type WslServerAcknowledgements = { + root: boolean + mismatch: { path: string; version: string } | null } -export type LocalServerStatus = - | { kind: "idle" } - | { kind: "ready" } - | { kind: "running"; step: LocalServerStep | null } - | { kind: "failed"; step: LocalServerStep | null; message: string } -export type LocalServerState = { - config: LocalServerConfig - runtime: { - key: string - mode: LocalServerMode - distro: string | null - } - status: LocalServerStatus - job: { step: LocalServerStep | null; startedAt: number } | null - checks: { - wsl: LocalServerWslCheck | null - distro: LocalServerDistroCheck | null - opencode: LocalServerOpencodeCheck | null - } - transcript: LocalServerTranscriptLine[] + +export type WslServerConfig = { + id: string + distro: string + acknowledgements: WslServerAcknowledgements } -export type LocalServerEvent = { - type: "state" - state: LocalServerState + +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 LocalServerAPI = { - getState: () => Promise - setConfig: (config: LocalServerConfig) => Promise - runStep: (step: LocalServerStep) => Promise - cancelJob: () => Promise + +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 + opencodeChecks: Record + pendingRestart: boolean + servers: WslServerItem[] + job: WslJob | null + transcript: WslTranscriptLine[] + lastError: string | null +} +export type WslServersEvent = { type: "state"; state: WslServersState } + +export type WslServersAPI = { + getState: () => Promise + subscribe: (cb: (event: WslServersEvent) => void) => () => void + probeRuntime: () => Promise + refreshDistros: () => Promise installWsl: () => Promise installDistro: (name: string) => Promise - installOpencode: () => Promise - openTerminal: () => Promise - subscribe: (cb: (event: LocalServerEvent) => void) => () => void + probeDistro: (name: string) => Promise + probeOpencode: (name: string) => Promise + installOpencode: (name: string) => Promise + openTerminal: (name: string) => Promise + addServer: (distro: string) => Promise + removeServer: (id: string) => Promise + startServer: (id: string) => Promise + stopServer: (id: string) => Promise + cancelJob: () => Promise + updateAcknowledgements: (id: string, acks: Partial) => Promise } export type LinuxDisplayBackend = "wayland" | "auto" @@ -122,7 +128,7 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise - localServer: LocalServerAPI + wslServers: WslServersAPI getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise getDisplayBackend: () => Promise diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts index d1590ff0486b..3dbd50f61a88 100644 --- a/packages/desktop-electron/src/renderer/env.d.ts +++ b/packages/desktop-electron/src/renderer/env.d.ts @@ -5,8 +5,8 @@ declare global { api: ElectronAPI __OPENCODE__?: { updaterEnabled?: boolean - wsl?: boolean deepLinks?: string[] + activeServer?: string } } } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index df61018671a4..edf948d4d803 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -5,7 +5,6 @@ import { ACCEPTED_FILE_TYPES, AppBaseProviders, AppInterface, - DialogLocalServer, handleNotificationClick, loadLocaleDict, normalizeLocale, @@ -14,10 +13,12 @@ import { PlatformProvider, ServerConnection, useCommand, + type WslServersEvent, + type WslServersState, } from "@opencode-ai/app" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" -import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" @@ -25,8 +26,6 @@ import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" @@ -54,27 +53,14 @@ const listenForDeepLinks = () => { } function LocalServerStartupError(props: { message: string }) { - const dialog = useDialog() - return (

Local Server failed to start

{props.message}

-
@@ -90,21 +76,20 @@ const createPlatform = (): Platform => { return undefined })() - const wslDistro = async () => { - if (os !== "windows") return - const state = await window.api.localServer.getState().catch(() => null) - if (state?.config.mode !== "wsl") return - return state.config.distro + const activeWslDistro = () => { + const key = window.__OPENCODE__?.activeServer + if (!key || !key.startsWith("wsl:")) return undefined + return key.slice("wsl:".length) } const wslHome = async () => { - const distro = await wslDistro() + const distro = activeWslDistro() if (!distro) return undefined return window.api.wslPath("~", "windows", distro).catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - const distro = await wslDistro() + const distro = activeWslDistro() if (!result || !distro) return result if (Array.isArray(result)) { return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any @@ -139,6 +124,8 @@ const createPlatform = (): Platform => { } })() + const wslServersApi = os === "windows" ? window.api.wslServers : undefined + return { platform: "desktop", os, @@ -179,7 +166,7 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - const distro = await wslDistro() + const distro = activeWslDistro() if (distro) { const converted = await window.api.wslPath(path, "windows", distro).catch(() => null) if (converted) return converted @@ -247,17 +234,7 @@ const createPlatform = (): Platform => { await window.api.setDefaultServerUrl(url) }, - localServer: { - getState: () => window.api.localServer.getState(), - setConfig: (config) => window.api.localServer.setConfig(config), - runStep: (step) => window.api.localServer.runStep(step), - cancelJob: () => window.api.localServer.cancelJob(), - installWsl: () => window.api.localServer.installWsl(), - installDistro: (name) => window.api.localServer.installDistro(name), - installOpencode: () => window.api.localServer.installOpencode(), - openTerminal: () => window.api.localServer.openTerminal(), - subscribe: (cb) => window.api.localServer.subscribe(cb), - }, + wslServers: wslServersApi, getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) @@ -329,22 +306,52 @@ render(() => { ) const [locale] = createResource(loadLocale) + const [wslServers, setWslServers] = createSignal(null) + if (platform.wslServers) { + void platform.wslServers.getState().then((state) => setWslServers(state)) + const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) + onCleanup(off) + } + const servers = () => { const data = startup.latest?.sidecar - if (!data) return [] - const server: ServerConnection.Sidecar = { - displayName: "Local Server", - type: "sidecar", - ...(data.local.mode === "wsl" && data.local.distro - ? { variant: "wsl", distro: data.local.distro } - : { variant: "base" }), - http: { - url: data.url, - username: data.username ?? undefined, - password: data.password ?? undefined, - }, + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.local.url, + username: data.local.username ?? undefined, + password: data.local.password ?? undefined, + }, + }) + } + const wsl = wslServers() + if (wsl) { + for (const item of wsl.servers) { + const runtime = item.runtime + const http = + runtime.kind === "ready" + ? { + url: runtime.url, + username: runtime.username ?? undefined, + password: runtime.password ?? undefined, + } + : { + url: `http://wsl-${item.config.distro}.invalid`, + } + list.push({ + displayName: `WSL: ${item.config.distro}`, + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + http, + }) + } } - return [server] as ServerConnection.Any[] + return list } function handleClick(e: MouseEvent) { From c510661ef3bf0b4fcde1e12d2cdb1ea5f36623ec Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:15:24 +1000 Subject: [PATCH 42/88] fix: stop random hotkeys from snapping desktop zoom to 1 The main process was resetting webContents zoom to 1 on every \zoom-changed\ event, which fires not just for native Chromium zoom gestures but also for the renderer's own setZoomFactor IPC calls. Paired with a keydown listener that re-sent the current zoom on every ctrl-combination (ctrl+backspace, ctrl+z, ctrl+v, ...), this created a self-triggered race that intermittently snapped the factor back to 1. Make the renderer the single source of zoom truth: keyboard, wheel, and menu all drive the same Solid signal, preventDefault blocks Chromium's built-in accelerators before they race, and the main process disables pinch zoom and no longer listens to zoom-changed. --- packages/desktop-electron/src/main/menu.ts | 6 +- packages/desktop-electron/src/main/windows.ts | 8 +- .../desktop-electron/src/renderer/index.tsx | 5 +- .../src/renderer/webview-zoom.ts | 73 +++++++++++++++---- 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index fcf209fb6709..f55554a8ebb4 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -75,9 +75,9 @@ export function createMenu(deps: Deps) { { role: "reload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") }, + { label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") }, + { label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index b5cdf6c4d8b2..26f138f5fbf0 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -159,7 +159,9 @@ function injectGlobals(win: BrowserWindow, globals: Globals) { function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) - win.webContents.on("zoom-changed", () => { - win.webContents.setZoomFactor(1) - }) + // Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled + // in the renderer so the Solid `webviewZoom` signal stays the single source + // of truth; a stray `zoom-changed` handler here would race with the renderer + // and intermittently snap the factor back to 1. + void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined) } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index edf948d4d803..aa31dc3b64aa 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -23,7 +23,7 @@ import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { UPDATER_ENABLED } from "./updater" -import { webviewZoom } from "./webview-zoom" +import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom" import "./styles.css" import { Button } from "@opencode-ai/ui/button" import { Splash } from "@opencode-ai/ui/logo" @@ -265,6 +265,9 @@ const createPlatform = (): Platform => { let menuTrigger = null as null | ((id: string) => void) window.api.onMenuCommand((id) => { + if (id === "zoom.in") return zoomIn() + if (id === "zoom.out") return zoomOut() + if (id === "zoom.reset") return zoomReset() menuTrigger?.(id) }) listenForDeepLinks() diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a35f2..6ff35e2459c4 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -11,28 +11,73 @@ const OS_NAME = (() => { return "unknown" })() -const [webviewZoom, setWebviewZoom] = createSignal(1) +const MIN_ZOOM = 0.2 +const MAX_ZOOM = 10 +const KEY_STEP = 0.2 +const WHEEL_STEP = 0.1 -const MAX_ZOOM_LEVEL = 10 -const MIN_ZOOM_LEVEL = 0.2 +const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM) -const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) +const [webviewZoom, setWebviewZoom] = createSignal(1) -const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) +const apply = (next: number) => { + const clamped = clamp(next) + if (Math.abs(clamped - webviewZoom()) < 1e-6) return + setWebviewZoom(clamped) + void window.api.setZoomFactor(clamped).catch(() => undefined) } -window.addEventListener("keydown", (event) => { - if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return +export const zoomIn = () => apply(webviewZoom() + KEY_STEP) +export const zoomOut = () => apply(webviewZoom() - KEY_STEP) +export const zoomReset = () => apply(1) - let newZoom = webviewZoom() +// Seed the signal from the main process so renderer and webContents agree +// across cold starts, reloads, and HMR refreshes (which would otherwise +// reinitialize the signal to 1 while webContents kept its prior factor). +void window.api + .getZoomFactor() + .then((initial) => { + if (typeof initial === "number" && Number.isFinite(initial)) { + setWebviewZoom(clamp(initial)) + } + }) + .catch(() => undefined) - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 +// Keyboard accelerators. preventDefault stops Chromium's built-in zoom +// accelerators from firing in parallel (which previously caused races). +window.addEventListener("keydown", (event) => { + const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey + if (!mod || event.altKey) return - applyZoom(clamp(newZoom)) + if (event.key === "-" || event.key === "_") { + event.preventDefault() + zoomOut() + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + zoomIn() + return + } + if (event.key === "0") { + event.preventDefault() + zoomReset() + return + } }) +// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad +// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom +// as well as real ctrl+scroll / cmd+scroll. +window.addEventListener( + "wheel", + (event) => { + if (!event.ctrlKey && !event.metaKey) return + event.preventDefault() + const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP + apply(webviewZoom() + step) + }, + { passive: false }, +) + export { webviewZoom } From 3e7e709884be17142a24274d36bce41ec2e29e31 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:28:51 +1000 Subject: [PATCH 43/88] fix: preserve full error stacks in desktop renderer logs Electron's console-message event only surfaces {level, message, line, sourceId} without the stack, so uncaught errors showed up as 'line 1028 of chunk-*.js' (SolidJS's rethrow site) with no way to find the real origin. Attach window error and unhandledrejection listeners that log the full stack via console.error, and reshape the main-process log line so newlines in the stack survive instead of being JSON-escaped into one unreadable blob. --- packages/desktop-electron/src/main/index.ts | 12 +++++++---- .../desktop-electron/src/renderer/index.tsx | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 84720b30303d..f84414a5dc95 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -258,16 +258,20 @@ async function initialize() { function wireWindowDiagnostics(win: BrowserWindow, label: string) { win.webContents.on("console-message", (_event, level, message, line, sourceId) => { - const payload = { level, message, line, sourceId } + // Render `message` as a block so multi-line stack traces survive; the + // previous shape stuffed the message into a JSON object which escaped + // `\n` and made stacks unreadable. + const location = sourceId ? ` [${sourceId}:${line}]` : "" + const text = `${label} renderer${location}\n${message}` if (level >= 3) { - logger.error(`${label} renderer console`, payload) + logger.error(text) return } if (level >= 2) { - logger.warn(`${label} renderer console`, payload) + logger.warn(text) return } - logger.log(`${label} renderer console`, payload) + logger.log(text) }) win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index aa31dc3b64aa..79a599f354fc 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,5 +1,25 @@ // @refresh reload +// Install global error listeners before any other module runs so that +// uncaught errors and rejected promises reach the main process with their +// full stacks intact. Electron's `console-message` event only forwards the +// rethrow site, so without these we lose the originating frame. +window.addEventListener("error", (event) => { + const err = event.error + const stack = err instanceof Error ? err.stack : null + console.error( + "[renderer uncaught]", + stack ?? event.message, + stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`, + ) +}) + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason + const stack = reason instanceof Error ? reason.stack : null + console.error("[renderer unhandled rejection]", stack ?? reason) +}) + import { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, From 3360480a2a0fa2a76454e0d8c9deae926dad2876 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:31:35 +1000 Subject: [PATCH 44/88] fix: bind F12 and Ctrl+Shift+I to DevTools on all platforms --- packages/desktop-electron/src/main/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index f84414a5dc95..4317b731c897 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -294,6 +294,18 @@ function wireWindowDiagnostics(win: BrowserWindow, label: string) { }) }) + // DevTools accelerators on Windows/Linux where the menu isn't created. + win.webContents.on("before-input-event", (_event, input) => { + if (input.type !== "keyDown") return + const key = input.key + const toggle = + key === "F12" || + (input.control && input.shift && (key === "I" || key === "i")) || + (input.meta && input.alt && (key === "I" || key === "i")) + if (!toggle) return + win.webContents.toggleDevTools() + }) + win.on("unresponsive", () => { logger.error(`${label} window became unresponsive`) }) From cfcc6f1353dbc46009ffe9f6885c05f90767adf5 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:46:12 +1000 Subject: [PATCH 45/88] fix: batch server switch to avoid cleanNode crash The status popover and select-server dialog used to call navigate('/') then defer server.setActive to the next microtask. With multiple sidecars in v2, that split triggered two separate disposal cascades - one for the route change and a second for the ServerKey Show re-key - and the sidebar project bucket also swaps (local -> wsl:Debian), tearing down every solid-dnd sortable in the middle. Wrapping both calls in batch() lands them in a single Solid update so disposal runs once. Also raise Error.stackTraceLimit to 200 so future disposal crashes capture the originating frame instead of truncating at the tenth cleanNode. --- packages/app/src/components/dialog-select-server.tsx | 8 +++++--- packages/app/src/components/status-popover-body.tsx | 11 ++++++++--- packages/desktop-electron/src/renderer/index.tsx | 6 ++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 01cb280e5df7..93eaf0df49c1 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -364,8 +364,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { props.onNavigateHome?.() return } - props.onNavigateHome?.() - queueMicrotask(() => server.setActive(ServerConnection.key(conn))) + batch(() => { + props.onNavigateHome?.() + server.setActive(ServerConnection.key(conn)) + }) } const handleAddChange = (value: string) => { diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index e94c9f112e8e..0075f501de9b 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" @@ -292,8 +292,13 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - navigate("/") - queueMicrotask(() => server.setActive(key)) + // Run navigate + setActive in the same tick so Solid + // disposes the old subtree once instead of cascading + // the route change disposal into the ServerKey remount. + batch(() => { + navigate("/") + server.setActive(key) + }) }} > diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 79a599f354fc..5e26f65efc8b 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,5 +1,11 @@ // @refresh reload +// V8's default Error.stackTraceLimit truncates at 10 frames, which is exactly +// the depth of the recursive cleanNode crash — the real trigger (our code +// calling dispose, or a store update racing disposal) is beyond that. Raise +// it so stacks contain the origin frame. +Error.stackTraceLimit = 200 + // Install global error listeners before any other module runs so that // uncaught errors and rejected promises reach the main process with their // full stacks intact. Electron's `console-message` event only forwards the From d04d13ea223c1e245bebf05e2ec6af0c27152a53 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:50:49 +1000 Subject: [PATCH 46/88] fix: defer child-store root disposal to avoid nested cleanNode disposeDirectory called a createRoot dispose() synchronously. When triggered by pinForOwner's onCleanup during a parent remount (e.g. switching to a WSL server re-keys the ServerKey Show), the inner dispose ran a nested cleanNode cascade on a sibling root while the outer cascade was mid-traversal, corrupting solid-js's graph walk state and surfacing as TypeError: Cannot read properties of null (reading '1') at chunk-*.js:992 after ~155 recursive cleanNode frames. Queue the dispose on a microtask so synchronous bookkeeping still runs (map deletes, onDispose cache invalidation) but the reactive cleanup happens after the outer traversal finishes. --- packages/app/src/context/global-sync/child-store.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 3fe67e4fbe65..29202110af84 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -96,8 +96,15 @@ export function createChildStoreManager(input: { lifecycle.delete(directory) const dispose = disposers.get(directory) if (dispose) { - dispose() disposers.delete(directory) + // Defer the actual solid-js root disposal. When disposeDirectory runs + // from pinForOwner's onCleanup during a parent remount, calling + // dispose() here triggers a nested cleanNode cascade on the inner + // root while the outer cascade is mid-traversal, which corrupts + // solid-js's graph walk state and throws `Cannot read properties of + // null (reading '1')` at chunk-*.js:992. Running dispose on a + // microtask lets the outer cleanup finish first. + queueMicrotask(dispose) } delete children[directory] input.onDispose(directory) From 33f5b80235246e770edbf397eff6a2fbcb7d7d45 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:57:56 +1000 Subject: [PATCH 47/88] fix: defer reactive root disposal in cache cleanups Same nested-dispose-in-onCleanup bug as 7f36ac2481 but in three more places: TerminalProvider.disposeAll, PromptProvider.disposeAll, and scoped-cache.clear() (covers viewCache.clear and comments cache.clear). All of them synchronously call createRoot dispose() on cached entries inside onCleanup, which during a server switch nests into the outer cleanNode cascade and throws TypeError at chunk-*.js:992. Snapshot the pending disposers, clear the cache synchronously, and fire the disposers on a microtask so the outer cleanup finishes first. --- packages/app/src/context/prompt.tsx | 9 ++++++--- packages/app/src/context/terminal.tsx | 11 ++++++++--- packages/app/src/utils/scoped-cache.test.ts | 5 ++++- packages/app/src/utils/scoped-cache.ts | 17 ++++++++++++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 9b666e5e751a..0d1cee710742 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + // Defer the dispose calls to a microtask; synchronous nested dispose + // inside a parent onCleanup corrupts solid-js's in-flight cleanNode + // traversal during mass remounts (see context/terminal.tsx for the + // same pattern). + const pending = Array.from(cache.values(), (entry) => entry.dispose) cache.clear() + if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) } onCleanup(disposeAll) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 31d2d6e04ca8..482f55c7163e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -364,10 +364,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(() => caches.delete(cache)) const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + // Snapshot disposers, then defer them to a microtask. 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 + // solid-js's graph walk state and throwing `Cannot read properties of + // 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())) } onCleanup(disposeAll) diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts index 0c6189dafe56..26821134c8a7 100644 --- a/packages/app/src/utils/scoped-cache.test.ts +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -24,7 +24,7 @@ describe("createScopedCache", () => { expect(disposed).toEqual(["b"]) }) - test("disposes entries on delete and clear", () => { + test("disposes entries on delete and clear", async () => { const disposed: string[] = [] const cache = createScopedCache((key) => ({ key }), { dispose: (value) => disposed.push(value.key), @@ -39,6 +39,9 @@ describe("createScopedCache", () => { cache.clear() expect(cache.peek("b")).toBeUndefined() + // clear() defers dispose to a microtask to avoid nested cleanNode cascades + // when called from inside an onCleanup; flush the queue before asserting. + await Promise.resolve() expect(disposed).toEqual(["a", "b"]) }) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts index 224c363c1ebc..7044cdf03c63 100644 --- a/packages/app/src/utils/scoped-cache.ts +++ b/packages/app/src/utils/scoped-cache.ts @@ -89,10 +89,21 @@ export function createScopedCache(createValue: (key: string) => T, options: S } const clear = () => { - for (const [key, entry] of store) { - dispose(key, entry) - } + // Defer dispose() calls to a microtask. When clear() runs inside an + // onCleanup during a parent remount (e.g. context/file.tsx and + // context/comments.tsx both do this), synchronous dispose on cached + // createRoot entries starts a nested cleanNode cascade while the outer + // cascade is mid-traversal, corrupting solid-js's graph walk state and + // throwing `Cannot read properties of null (reading '1')` at + // chunk-*.js:992. Deferring lets the outer cleanup finish first. + const pending: Array<[string, Entry]> = [] + for (const entry of store) pending.push(entry) store.clear() + if (pending.length && options.dispose) { + queueMicrotask(() => { + for (const [key, entry] of pending) dispose(key, entry) + }) + } } return { From 902ac2dad94f042181a248c6ed0aefbcbec52e9f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:53:20 +1000 Subject: [PATCH 48/88] chore: add solid owner/cleanup instrumentation for diagnosing cleanNode crashes Hooks DEV.hooks.afterCreateOwner to wrap every owner's .owned and .cleanups with accessor traps that record every mutation to a ring buffer with tags, stacks, and cleanup-depth context. On any 'Cannot read properties of null' TypeError the buffer is dumped so the offending cleanup/origin that nulled an owner's owned mid-iteration is visible post-hoc. Also wraps owned arrays in a Proxy so cleanNode's index reads are logged and the suspect ownerTag at crash time can be identified. Debug only; zero cost until a crash fires. --- .../desktop-electron/src/renderer/index.tsx | 7 + .../src/renderer/solid-instrument.ts | 275 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 packages/desktop-electron/src/renderer/solid-instrument.ts diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 5e26f65efc8b..dee4990dcfe4 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,13 @@ // it so stacks contain the origin frame. Error.stackTraceLimit = 200 +// Install the solid-js owner/cleanup instrumentation before anything else +// touches solid-js so every created owner gets accessor-based traps on its +// `owned` and `cleanups`. This logs the exact cleanup-cascade that nulls an +// owner's `owned` mid-iteration — the root cause of the recursive cleanNode +// crash. Debug-only; remove once the offending cleanup is identified. +import "./solid-instrument" + // Install global error listeners before any other module runs so that // uncaught errors and rejected promises reach the main process with their // full stacks intact. Electron's `console-message` event only forwards the diff --git a/packages/desktop-electron/src/renderer/solid-instrument.ts b/packages/desktop-electron/src/renderer/solid-instrument.ts new file mode 100644 index 000000000000..c4bf8e11ad22 --- /dev/null +++ b/packages/desktop-electron/src/renderer/solid-instrument.ts @@ -0,0 +1,275 @@ +// Debug-only instrumentation for the recursive cleanNode crash +// ("Cannot read properties of null (reading '1')" at node.owned[i]). +// +// The crash stack has ~150 pure cleanNode frames with no cleanup frame +// between them, so the null assignment doesn't happen live during the +// crashing call. Something earlier (an earlier cleanup or earlier cascade) +// nulled an owner's .owned while it was still referenced from another +// computation's owned list, and the later cleanNode recursion walks into it. +// +// To find that, we: +// 1. Install an accessor trap via DEV.hooks.afterCreateOwner that records +// every owned = mutation with a tag, a short stack, and +// whether a cleanup was currently running. Pushed into a ring buffer. +// 2. On any uncaught TypeError we dump the ring buffer to the console. +// 3. Attach a __solidTag to every owner so we can correlate. +// +// The module must be imported before anything else touches solid-js so the +// first owner created (the render root) is instrumented. + +import { DEV } from "solid-js" + +type CleanupEntry = { originFrames: string[]; runAtFrames: string[] } +type OwnedEvent = { + ts: number + ownerTag: number + action: "set-null" | "set-array" | "initial-null" + prevLen: number | null + nextLen: number | null + cleanupDepth: number + cleanNodeFramesAbove: number + cleanNodeFramesBelow: number + topCleanupOrigin: string[] | null + stackHead: string[] +} + +type OwnedAccess = { + ownerTag: number + prop: string + hit: boolean + ts: number +} + +declare global { + // eslint-disable-next-line no-var + var __SOLID_CLEANUP_STACK: CleanupEntry[] + // eslint-disable-next-line no-var + var __SOLID_OWNED_EVENTS: OwnedEvent[] + // eslint-disable-next-line no-var + var __SOLID_OWNERS_BY_TAG: Map + // eslint-disable-next-line no-var + var __SOLID_DUMP_DONE: boolean + // eslint-disable-next-line no-var + var __SOLID_LAST_OWNED_ACCESS: OwnedAccess | null + // eslint-disable-next-line no-var + var __SOLID_OWNED_ACCESS_LOG: OwnedAccess[] +} + +const RING_SIZE = 500 +const ACCESS_LOG_SIZE = 50 + +globalThis.__SOLID_CLEANUP_STACK = globalThis.__SOLID_CLEANUP_STACK ?? [] +globalThis.__SOLID_OWNED_EVENTS = globalThis.__SOLID_OWNED_EVENTS ?? [] +globalThis.__SOLID_OWNERS_BY_TAG = globalThis.__SOLID_OWNERS_BY_TAG ?? new Map() +globalThis.__SOLID_DUMP_DONE = false +globalThis.__SOLID_LAST_OWNED_ACCESS = null +globalThis.__SOLID_OWNED_ACCESS_LOG = [] + +const stackFrames = (err: Error, n = 30): string[] => { + const lines = (err.stack ?? "").split("\n") + return lines + .slice(1, 1 + n) + .map((l) => l.trim()) + .filter((l) => l.startsWith("at ")) +} + +const isCleanNodeFrame = (f: string) => f.startsWith("at cleanNode ") +const isWrappedCleanupFrame = (f: string) => f.includes("wrappedCleanup") + +const pushEvent = (ev: OwnedEvent) => { + const buf = globalThis.__SOLID_OWNED_EVENTS + buf.push(ev) + if (buf.length > RING_SIZE) buf.splice(0, buf.length - RING_SIZE) +} + +const wrapCleanup = (fn: Function, node: any): Function => { + const originFrames = stackFrames(new Error("onCleanup-site"), 20) + function wrappedCleanup(this: unknown, ...args: unknown[]) { + const entry: CleanupEntry = { + originFrames, + runAtFrames: stackFrames(new Error("cleanup-run"), 15), + } + globalThis.__SOLID_CLEANUP_STACK.push(entry) + try { + return fn.apply(this, args) + } finally { + globalThis.__SOLID_CLEANUP_STACK.pop() + } + } + ;(wrappedCleanup as any).__original = fn + ;(wrappedCleanup as any).__originFrames = originFrames + ;(wrappedCleanup as any).__ownerTag = node.__solidTag + return wrappedCleanup +} + +const wrapOwnedArray = (arr: any[], node: any): any[] => { + // Proxy the owned array so we can log every numeric-index read. + // cleanNode iterates via `node.owned[i]` — the crashing access is exactly + // such a read that returns an index on a null array (but our owned is + // always an array or null, never a null array access via this proxy). + // We log what the CURRENT iteration is reading so the crash handler can + // name the owner whose owned was just touched. + return new Proxy(arr, { + get(target, prop, recv) { + if (typeof prop === "string") { + const n = Number(prop) + if (Number.isInteger(n) && n >= 0) { + const entry: OwnedAccess = { + ownerTag: node.__solidTag, + prop, + hit: n in target, + ts: Date.now(), + } + globalThis.__SOLID_LAST_OWNED_ACCESS = entry + const log = globalThis.__SOLID_OWNED_ACCESS_LOG + log.push(entry) + if (log.length > ACCESS_LOG_SIZE) log.splice(0, log.length - ACCESS_LOG_SIZE) + } + } + return Reflect.get(target, prop, recv) + }, + }) +} + +const wrapCleanupsArray = (arr: any[], node: any): any[] => { + const existing = arr.slice() + arr.length = 0 + for (const fn of existing) Array.prototype.push.call(arr, wrapCleanup(fn, node)) + + const origPush = arr.push.bind(arr) + Object.defineProperty(arr, "push", { + configurable: true, + writable: true, + value: (...fns: Function[]) => { + const wrapped = fns.map((fn) => wrapCleanup(fn, node)) + return origPush(...wrapped) + }, + }) + return arr +} + +if (DEV?.hooks) { + let tagCounter = 0 + const prev = DEV.hooks.afterCreateOwner + DEV.hooks.afterCreateOwner = (node: any) => { + if (prev) prev(node) + if (node.__solidTag !== undefined) return + node.__solidTag = ++tagCounter + globalThis.__SOLID_OWNERS_BY_TAG.set(node.__solidTag, node) + try { + node.__createdAtFrames = stackFrames(new Error("owner-created"), 12) + } catch { + /* ignore */ + } + + let ownedValue: any[] | null = Array.isArray(node.owned) ? wrapOwnedArray(node.owned, node) : (node.owned ?? null) + Object.defineProperty(node, "owned", { + configurable: true, + enumerable: true, + get() { + return ownedValue + }, + set(v: any[] | null) { + const prevArr = ownedValue + if (Array.isArray(v)) v = wrapOwnedArray(v, node) + // Record every owned mutation so we can post-hoc trace corruption. + const frames = stackFrames(new Error("owned-set"), 30) + const wrappedIdx = frames.findIndex(isWrappedCleanupFrame) + const cleanupDepth = globalThis.__SOLID_CLEANUP_STACK.length + pushEvent({ + ts: Date.now(), + ownerTag: node.__solidTag, + action: prevArr == null && v == null ? "initial-null" : v == null ? "set-null" : "set-array", + prevLen: prevArr == null ? null : prevArr.length, + nextLen: v == null ? null : v.length, + cleanupDepth, + cleanNodeFramesAbove: wrappedIdx >= 0 ? frames.slice(wrappedIdx + 1).filter(isCleanNodeFrame).length : 0, + cleanNodeFramesBelow: + wrappedIdx >= 0 + ? frames.slice(0, wrappedIdx).filter(isCleanNodeFrame).length + : frames.filter(isCleanNodeFrame).length, + topCleanupOrigin: cleanupDepth > 0 ? globalThis.__SOLID_CLEANUP_STACK[cleanupDepth - 1]!.originFrames : null, + stackHead: frames.slice(0, 8), + }) + ownedValue = v + }, + }) + + let cleanupsValue: any[] | null = node.cleanups ?? null + if (Array.isArray(cleanupsValue)) cleanupsValue = wrapCleanupsArray(cleanupsValue, node) + Object.defineProperty(node, "cleanups", { + configurable: true, + enumerable: true, + get() { + return cleanupsValue + }, + set(v: any[] | null) { + if (Array.isArray(v)) { + cleanupsValue = wrapCleanupsArray(v, node) + } else { + cleanupsValue = v + } + }, + }) + } + try { + console.log("[solid-instrument] installed afterCreateOwner hook") + } catch { + /* ignore */ + } +} + +// When the cleanNode TypeError fires, dump everything we've recorded so we +// can see which owners had their .owned nulled in the moments just before +// the crash. Install at capture phase so we run before any other handler. +const dumpOwnedHistory = (label: string) => { + if (globalThis.__SOLID_DUMP_DONE) return + globalThis.__SOLID_DUMP_DONE = true + try { + const events = globalThis.__SOLID_OWNED_EVENTS + // Last 20 events + last 15 set-null events are plenty for correlation. + const tail = events.slice(-20) + const nulls = events.filter((e) => e.action === "set-null").slice(-15) + const lastAccess = globalThis.__SOLID_LAST_OWNED_ACCESS + const accessLog = globalThis.__SOLID_OWNED_ACCESS_LOG.slice(-25) + + // Pull the ownerTag the crash was iterating and see if that tag was + // set-null'd in the recent events ring. That is the smoking gun. + const suspectTag = lastAccess?.ownerTag + const suspectNull = + suspectTag != null ? events.filter((e) => e.ownerTag === suspectTag && e.action === "set-null") : [] + + console.error( + `[${label}] SUSPECT OWNER AT CRASH:`, + JSON.stringify( + { + lastOwnedAccess: lastAccess, + suspectTag, + suspectOwnerCreatedAt: + suspectTag != null ? globalThis.__SOLID_OWNERS_BY_TAG.get(suspectTag)?.__createdAtFrames : null, + suspectSetNullEvents: suspectNull, + }, + null, + 2, + ), + ) + console.error(`[${label}] last ${accessLog.length} owned[i] accesses:`, JSON.stringify(accessLog, null, 2)) + console.error(`[${label}] last ${nulls.length} set-null events:`, JSON.stringify(nulls, null, 2)) + console.error(`[${label}] last ${tail.length} owned mutation events:`, JSON.stringify(tail, null, 2)) + } catch (e) { + console.error(`[${label}] dump failed`, e) + } +} + +window.addEventListener( + "error", + (ev) => { + const msg = (ev.error && ev.error.message) || ev.message || "" + if (typeof msg === "string" && msg.includes("Cannot read properties of null")) { + dumpOwnedHistory("SOLID CLEANNODE CRASH") + } + }, + true, +) + +export {} From 8fd7bd19d68d22d9d8b954c28737c561dd2daa85 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:53:42 +1000 Subject: [PATCH 49/88] fix: defer terminal cleanup state write to stop cleanNode reentry crash Terminal onCleanup ran persistTerminal synchronously during a dispose cascade, which flowed through props.onCleanup -> ops.update -> update() in context/terminal.tsx and fired setStore on the terminal store. That store write reentered the reactive graph mid cleanNode iteration; solid then nulled an ancestors owned while an outer cleanNode recursion was still iterating it, crashing with Cannot read properties of null reading 1 at node.owned[i]. Wrapping finalize in queueMicrotask pushes the store write past the current synchronous cleanup cascade so the teardown cannot race with cleanNodes owned walk. --- packages/app/src/components/terminal.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index bf87e67c2aef..edbbd752c957 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -613,17 +613,30 @@ export const Terminal = (props: TerminalProps) => { drop?.() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) + // Defer finalize (persistTerminal + local cleanup()) to a microtask so + // that its synchronous store write inside `persistTerminal` — which + // flows through `props.onCleanup` -> `ops.update` -> `update()` in + // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does + // NOT run inside the outer solid cleanNode cascade. Running it + // synchronously mid-cascade races with solid's recursive owned + // iteration (readSignal on a stale memo re-enters updateComputation, + // which nulls an ancestor's owned while the outer loop is still + // iterating it) and crashes with "Cannot read properties of null + // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode. + // queueMicrotask runs after the current sync reactive flush, so the + // store write lands in a fresh tick. const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) cleanup() } + const schedule = () => queueMicrotask(finalize) if (!output) { - finalize() + schedule() return } - output.flush(finalize) + output.flush(schedule) }) return ( From da2e640029a0c6afbc2c7c04f6ef1624a3813de4 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:51:41 +1000 Subject: [PATCH 50/88] chore: improve desktop renderer diagnostics Serialize non-Error promise rejections so unhandled rejections print type/ctor/keys/JSON instead of the unreadable '[object Object]'. Also emit [server health] logs when a health poll returns unhealthy and when polling switches servers, so a red dot in the status popover comes with a logged URL and auth presence. Minor cosmetic: restore session-header StatusPopover import position after the earlier titlebar experiment. --- .../src/components/session/session-header.tsx | 2 +- packages/app/src/context/server.tsx | 19 ++++++++++- .../desktop-electron/src/renderer/index.tsx | 32 +++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cf57f4408d91..3b4bda7f27e0 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" +import { StatusPopover } from "../status-popover" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/shared/util/path" import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" @@ -24,7 +25,6 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" -import { StatusPopover } from "../status-popover" const OPEN_APPS = [ "vscode", diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index f68031548616..372f1dad29d2 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -200,7 +200,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) - const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) + const check = (conn: ServerConnection.Any) => + checkServerHealth(conn.http).then((x) => { + if (!x.healthy) { + // Loud: makes it trivial to see why a server shows red in the + // status popover / switcher. The dot only goes red when this + // returns false; otherwise undefined (gray) is emitted first. + console.warn("[server health] unhealthy", { + key: ServerConnection.key(conn), + url: conn.http.url, + hasAuth: !!(conn.http.username || conn.http.password), + }) + } + return x.healthy + }) createEffect(() => { const current_ = current() @@ -211,6 +224,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( return } setState("healthy", undefined) + console.log("[server health] start polling", { + key: ServerConnection.key(current_), + url: current_.http.url, + }) onCleanup(startHealthPolling(current_)) }) diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index dee4990dcfe4..e9b105aa3948 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -29,8 +29,36 @@ window.addEventListener("error", (event) => { window.addEventListener("unhandledrejection", (event) => { const reason = event.reason - const stack = reason instanceof Error ? reason.stack : null - console.error("[renderer unhandled rejection]", stack ?? reason) + // Log as much as possible: stack for Errors, JSON for plain objects with + // a fallback to a tagged shape so we never end up with just + // "[object Object]" in main.log. + if (reason instanceof Error) { + console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason)) + return + } + let serialized: string + try { + serialized = JSON.stringify( + reason, + (_key, value) => { + if (value instanceof Error) { + return { __error: true, name: value.name, message: value.message, stack: value.stack } + } + return value + }, + 2, + ) + } catch { + serialized = String(reason) + } + console.error( + "[renderer unhandled rejection]", + `type=${typeof reason}`, + `ctor=${reason?.constructor?.name ?? "null"}`, + `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`, + "value:", + serialized, + ) }) import { From bff9e576b7a2c75ad516ea88b24da67fd3f15bb0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:51:53 +1000 Subject: [PATCH 51/88] feat(desktop): show splash overlay during server switch ServerKey's keyed remount is a multi-second synchronous cascade (dispose + rebuild of the whole app subtree) that used to leave the UI looking frozen. A tiny module-level serverSwitching signal now gates a fullscreen Splash rendered above the ServerKey boundary, and the status-popover click handler setTimeout-defers the batched navigate+setActive so the browser paints the splash before the freeze begins and dismisses it after the new subtree paints. --- packages/app/src/app.tsx | 12 ++++++++ .../src/components/status-popover-body.tsx | 28 ++++++++++++++----- packages/app/src/utils/server-switch.tsx | 9 ++++++ 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/utils/server-switch.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 362ac271e943..2ff68b9dd5f2 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -28,6 +28,7 @@ import { Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" +import { serverSwitching } from "@/utils/server-switch" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -305,6 +306,12 @@ export function AppInterface(props: { router?: Component disableHealthCheck?: boolean }) { + // ServerKey wraps the whole Router so that switching `server.key` throws + // away any session / pty state from the previous server. Preserving the + // route across servers doesn't work because session ids, pty ids, and + // most URL-addressable resources are server-scoped — you'd 404 on every + // fetch. The click handler that swaps servers also navigates back to "/" + // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL. return ( + +
+ +
+
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0075f501de9b..cad0b0673a75 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { setServerSwitching } from "@/utils/server-switch" const pollMs = 10_000 @@ -292,13 +293,26 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - // Run navigate + setActive in the same tick so Solid - // disposes the old subtree once instead of cascading - // the route change disposal into the ServerKey remount. - batch(() => { - navigate("/") - server.setActive(key) - }) + // Paint a full-window splash BEFORE the heavy + // ServerKey remount so the user gets visual + // feedback during the multi-second synchronous + // dispose cascade (xterm + file-tree + providers). + // setTimeout(0) yields to the browser so the + // splash lands on screen before the cascade + // starts; a second setTimeout(0) after the batch + // waits for the new subtree to paint, then + // dismisses the splash. + setServerSwitching(true) + setTimeout(() => { + try { + batch(() => { + navigate("/") + server.setActive(key) + }) + } finally { + setTimeout(() => setServerSwitching(false), 0) + } + }, 0) }} > diff --git a/packages/app/src/utils/server-switch.tsx b/packages/app/src/utils/server-switch.tsx new file mode 100644 index 000000000000..480990b18477 --- /dev/null +++ b/packages/app/src/utils/server-switch.tsx @@ -0,0 +1,9 @@ +import { createSignal } from "solid-js" + +// Global flag used to paint a full-window splash overlay while a server +// swap is in progress. ServerKey's keyed remount is a big +// synchronous cascade (dispose + remount of the entire app subtree) that +// can freeze the UI for several seconds; setting this true before the +// swap and false after lets us render an overlay above the ServerKey +// boundary so the freeze has visual feedback instead of looking stuck. +export const [serverSwitching, setServerSwitching] = createSignal(false) From 3349fb95ca5422d10fbd8064ece7a3c384b146be Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:52:06 +1000 Subject: [PATCH 52/88] fix(layout): guard SortableWorkspace against undefined project during server swap bootstrapDirectory's setStore fires while a server swap is cascading; currentProject() can already be undefined by the time these memos re-run, which threw 'Cannot read properties of undefined (reading worktree)' from local/workspaceValue/WorkspaceActions root. Optional-chain the props.project reads so the component survives the transient undefined before the enclosing Show unmounts it. --- .../app/src/pages/layout/sidebar-workspace.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index e3b0ccd77c6b..b6685d35d7fd 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -319,12 +319,20 @@ export const SortableWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) - const local = createMemo(() => props.directory === props.project.worktree) + // Guard against `props.project` being transiently undefined during a + // server-switch cascade. The parent renders + // {(dir) => } + // where `project()` can flip to undefined while the enclosing + // gate hasn't yet unmounted this child. Bootstrap's setStore can then fire + // these memos with stale props. + const local = createMemo(() => props.directory === (props.project?.worktree ?? "")) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) - return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name + const projectId = props.project?.id + if (!projectId) return name + return props.ctx.workspaceName(props.directory, projectId, branch) ?? name }) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) @@ -423,7 +431,7 @@ export const SortableWorkspace = (props: { openEditor={props.ctx.openEditor} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} - root={props.project.worktree} + root={props.project?.worktree ?? props.directory} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} navigateToNewSession={() => navigate(`/${slug()}/session`)} /> From 4560435dd9c98076ac1613e50febdffa5667f475 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:52:10 +1000 Subject: [PATCH 53/88] fix(desktop-wsl): spawn WSL commands as root to bypass first-run setup A freshly installed Ubuntu-24.04 distro prompts interactively for a new UNIX user on its first invocation; with piped stdio that prompt blocks forever and the sidecar never starts. Adding --user root to wslArgs sidesteps the whole first-run flow for every wsl.exe we spawn (sidecar, resolveWslOpencode, probes). opencode inside WSL only needs an HTTP listener so running as root is fine. --- packages/desktop-electron/src/main/wsl.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 6f5c2bca17ae..7f441ceae96e 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -18,9 +18,17 @@ type RunWslOptions = { signal?: AbortSignal } +// `--user root` bypasses the distro's default-user requirement. A freshly +// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively +// for a username/password on its first invocation; when spawned with +// piped stdio that prompt blocks forever or silently reads garbage, +// leaving the sidecar hanging and the server unhealthy. Running as root +// sidesteps the entire first-run setup flow — opencode only needs an +// HTTP listener in the distro, not a per-user environment, so root is +// a safe default for the sidecar process. export function wslArgs(args: string[], distro?: string | null) { - if (distro) return ["-d", distro, "--", ...args] - return ["--", ...args] + if (distro) return ["-d", distro, "--user", "root", "--", ...args] + return ["--user", "root", "--", ...args] } export function runWsl(args: string[], opts: RunWslOptions = {}) { From 11528e43c0588973d7912104a40ccd669b7750e1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:00:13 +1000 Subject: [PATCH 54/88] chore(desktop-wsl): log sidecar start failures and flatten server-health logs The WSL controller previously only stored a failed startup message in its renderer state, so Ubuntu-style silent failures left no trace in main.log. Inject the main-process logger into the controller and emit wsl sidecar ready / wsl sidecar failed to start entries. Also flatten the [server health] renderer logs into a single string argument because Electron's console-message bridge truncates extra args to [object Object]. --- packages/app/src/context/server.tsx | 20 +++++++----------- packages/desktop-electron/src/main/index.ts | 21 +++++++++++++------ .../desktop-electron/src/main/wsl-servers.ts | 19 ++++++++++++----- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 372f1dad29d2..096ef23db91a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -203,14 +203,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => { if (!x.healthy) { - // Loud: makes it trivial to see why a server shows red in the - // status popover / switcher. The dot only goes red when this - // returns false; otherwise undefined (gray) is emitted first. - console.warn("[server health] unhealthy", { - key: ServerConnection.key(conn), - url: conn.http.url, - hasAuth: !!(conn.http.username || conn.http.password), - }) + // Electron's console-message bridge only preserves the first + // console argument, so pre-stringify everything into one string. + console.warn( + `[server health] unhealthy key=${ServerConnection.key(conn)} url=${conn.http.url} hasAuth=${!!( + conn.http.username || conn.http.password + )}`, + ) } return x.healthy }) @@ -224,10 +223,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( return } setState("healthy", undefined) - console.log("[server health] start polling", { - key: ServerConnection.key(current_), - url: current_.http.url, - }) + console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`) onCleanup(startHealthPolling(current_)) }) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 4317b731c897..95d688b2771b 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -56,13 +56,22 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() void serverReady.promise.catch(() => undefined) -const wslServers = createWslServersController(app.getVersion(), async (distro) => { +const wslServers = (() => { const logger = initLogging() - logger.log("spawning wsl sidecar", { distro }) - return spawnWslSidecar(distro, { - onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), - }) -}) + return 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 logger = initLogging() logger.log("app starting", { diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 5e2438aa09d9..2757be3a81ef 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -41,13 +41,19 @@ type RunningSidecar = { type SpawnSidecar = (distro: string) => Promise +type ControllerLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + export type WslServersController = ReturnType export function wslServerIdForDistro(distro: string) { return `wsl:${distro}` } -export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar) { +export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { + const mainLogger: ControllerLogger | undefined = logger let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() @@ -124,11 +130,14 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa username: sidecar.username, password: sidecar.password, }) + mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { - setRuntime(id, { - kind: "failed", - message: error instanceof Error ? error.message : String(error), - }) + const message = error instanceof Error ? error.message : String(error) + 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. + mainLogger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) } } From 0e7e79100832b134fcd8200f406ed1de675d51d8 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:05:00 +1000 Subject: [PATCH 55/88] fix(layout): guard LocalWorkspace + projectId prop against undefined project Previous commit only guarded SortableWorkspace. LocalWorkspace (rendered from layout.tsx:2243 with the same project={project()!} pattern) still accessed props.project.worktree directly inside four createMemo / useQuery bodies and threw the same 'worktree of undefined' during a server swap cascade. Route every worktree read through a worktree memo that returns the string or '', and guard the projectId prop on WorkspaceHeader. --- .../src/pages/layout/sidebar-workspace.tsx | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index b6685d35d7fd..a0b8276ef71b 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -362,7 +362,7 @@ export const SortableWorkspace = (props: { InlineEditor={props.ctx.InlineEditor} renameWorkspace={props.ctx.renameWorkspace} setEditor={props.ctx.setEditor} - projectId={props.project.id} + projectId={props.project?.id ?? ""} /> ) @@ -465,19 +465,33 @@ export const LocalWorkspace = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() + // Same guard pattern as SortableWorkspace: the parent passes + // `project={project()!}` but `project()` can transiently flip to + // undefined during a server-switch cascade before this component + // unmounts, so every reactive memo reading props.project has to + // tolerate undefined. + const worktree = createMemo(() => props.project?.worktree ?? "") const workspace = createMemo(() => { - const [store, setStore] = globalSync.child(props.project.worktree) + const dir = worktree() + if (!dir) return undefined + const [store, setStore] = globalSync.child(dir) return { store, setStore } }) - const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) + const slug = createMemo(() => (worktree() ? base64Encode(worktree()) : "")) + const sessions = createMemo(() => { + const store = workspace()?.store + return store ? sortedRootSessions(store, props.sortNow()) : [] + }) + const booted = createMemo((prev) => prev || workspace()?.store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) - const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) - const hasMore = createMemo(() => workspace().store.sessionTotal > count()) - const loading = () => query.isLoading && count() === 0 + const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) })) + const loading = createMemo(() => query.isPending && count() === 0) + const hasMore = createMemo(() => (workspace()?.store.sessionTotal ?? 0) > count()) const loadMore = async () => { - workspace().setStore("limit", (limit) => (limit ?? 0) + 5) - await globalSync.project.loadSessions(props.project.worktree) + const dir = worktree() + if (!dir) return + workspace()?.setStore("limit", (limit) => (limit ?? 0) + 5) + await globalSync.project.loadSessions(dir) } return ( From 6fc5f342ddee0572b5affce378b766881e955908 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:23:10 +1000 Subject: [PATCH 56/88] fix(desktop-wsl): time-bound every wsl.exe invocation to fail fast on wedge Ubuntu-24.04 in the failed first-run state wedges wsl.exe silently - no stdout, no exit. Without a timeout resolveWslOpencode (and any other runWsl call) blocks the sidecar spawn flow forever, which hides the real failure from logs and from the controller's failed runtime state. Add a 20s default ceiling to runCommand; caller can override for long-running jobs (installs). --- packages/desktop-electron/src/main/wsl.ts | 31 ++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 7f441ceae96e..3ad523b7896a 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -16,8 +16,19 @@ export type WslCommandResult = { type RunWslOptions = { onLine?: (line: WslCommandLine) => void 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 + // `--user root` bypasses the distro's default-user requirement. A freshly // installed WSL distro (Ubuntu-24.04 in particular) prompts interactively // for a username/password on its first invocation; when spawned with @@ -51,6 +62,20 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { 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 = "" let stdoutPending = "" @@ -97,8 +122,12 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { stderrPending = flush("stderr", stderrPending) }) - child.once("error", reject) + child.once("error", (error) => { + clearTimeout(timeoutId) + reject(error) + }) child.once("close", (code, signal) => { + clearTimeout(timeoutId) resolve({ code, signal, stdout, stderr }) }) }) From 16ada93dd4036f0d3ebad440e6461f42fba30e3b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:29:50 +1000 Subject: [PATCH 57/88] fix(desktop-wsl): skip sidecar spawn when distro first-run is incomplete Reading HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss tells us the DefaultUid for every registered distro without touching wsl.exe. On a freshly installed Ubuntu-24.04 the 'Create a default UNIX user account' prompt never ran and DefaultUid stays 0; every wsl.exe -d ... invocation in that state silently blocks on stdin forever, even with --user root. checkWslDistroFirstRun reads the registry via powershell, and spawnWslSidecar throws a human-readable error if the distro still needs setup, so the controller marks the server as failed with a clear message instead of hanging. Also emits 'wsl sidecar starting' to main.log for visibility. --- packages/desktop-electron/src/main/server.ts | 19 +++++- .../desktop-electron/src/main/wsl-servers.ts | 1 + packages/desktop-electron/src/main/wsl.ts | 59 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index da856cf55840..cf2e5e34915a 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -5,7 +5,7 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" -import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" +import { checkWslDistroFirstRun, type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -83,6 +83,23 @@ export async function spawnWslSidecar( distro: string, opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { + // Gate on the registry state BEFORE any wsl.exe invocation. If the + // distro still has DefaultUid=0 it means the interactive first-run + // "Create a default UNIX user account" prompt never completed, and + // every wsl.exe -d ... call will silently block on stdin + // forever (we verified: Ubuntu-24.04 hangs on both -- echo and + // --user root -- echo in this state). Fail fast with a clear message + // so the controller can surface it to the user. + const firstRun = await checkWslDistroFirstRun(distro) + if (firstRun.status === "not-installed") { + throw new Error(`WSL distro ${distro} is not installed`) + } + if (firstRun.status === "needs-first-run") { + throw new Error( + `WSL distro ${distro} has not completed first-run setup. Open a terminal and run 'wsl -d ${distro}' to create a default UNIX user, then retry.`, + ) + } + const opencode = await resolveWslOpencode(distro) if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 2757be3a81ef..e60157fc3f78 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -121,6 +121,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa if (!item) return await stopServerInternal(id) setRuntime(id, { kind: "starting" }) + mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { const sidecar = await spawnSidecar(item.config.distro) sidecars.set(id, sidecar) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 3ad523b7896a..8a4b31e1c999 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -159,6 +159,65 @@ export function runWslInDistro(args: string[], distro?: string | null, opts?: Ru return runWsl(wslArgs(args, distro), opts) } +export type WslRegistryDistro = { + name: string + defaultUid: number + state: number + version: number +} + +// Distros that are designed to run as root and don't have a user-level +// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that +// prompts for a UNIX username on first invocation; if that never runs, +// wsl.exe -d hangs silently forever. +const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) + +// Read LXSS metadata from the Windows registry. This never invokes +// wsl.exe, so it is safe to call when wsl.exe itself is wedged. +// DefaultUid === 0 on a user-oriented distro means the first-run +// "Create a default UNIX user account" step never completed. +export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise { + const script = [ + "$ErrorActionPreference = 'Stop'", + "$out = @()", + "Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' -ErrorAction SilentlyContinue | ForEach-Object {", + " $name = $_.GetValue('DistributionName')", + " if (-not $name) { return }", + " $out += [PSCustomObject]@{", + " name = $name", + " defaultUid = [int]$_.GetValue('DefaultUid', 0)", + " state = [int]$_.GetValue('State', 0)", + " version = [int]$_.GetValue('Version', 0)", + " }", + "}", + "$out | ConvertTo-Json -Compress", + ].join("; ") + const result = await runPowerShell(script, opts) + if (result.code !== 0) return [] + const text = result.stdout.trim() + if (!text) return [] + try { + const parsed = JSON.parse(text) as WslRegistryDistro | WslRegistryDistro[] + return Array.isArray(parsed) ? parsed : [parsed] + } catch { + return [] + } +} + +export type WslFirstRunCheck = + | { status: "ok" } + | { status: "needs-first-run"; defaultUid: number } + | { status: "not-installed" } + +export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise { + const distros = await readWslDistrosFromRegistry(opts) + const entry = distros.find((d) => d.name === distro) + if (!entry) return { status: "not-installed" } + if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" } + if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid } + return { status: "ok" } +} + export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { return runWslInDistro(["sh", "-lc", script], distro, opts) } From 3822c0aaec5614a669102ca36692157f91b189d0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:34:50 +1000 Subject: [PATCH 58/88] fix(desktop-wsl): use reg.exe for LXSS registry probe instead of powershell The PowerShell HKCU Registry-provider enumeration returned nothing when spawned by Electron's main process (both Ubuntu-24.04 and Debian were reported as 'not installed'), so the first-run gate blocked every distro unconditionally. reg.exe is a native Windows binary with a stable line-oriented output format that works regardless of PS host quirks; parse its 'HKEY_CURRENT_USER\\\\...\\\\Lxss\\\\{guid}' subkey blocks, pull DistributionName + DefaultUid + State + Version, and rebuild the list. --- packages/desktop-electron/src/main/wsl.ts | 65 +++++++++++++++-------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 8a4b31e1c999..54b23800205e 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -176,32 +176,51 @@ const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) // wsl.exe, so it is safe to call when wsl.exe itself is wedged. // DefaultUid === 0 on a user-oriented distro means the first-run // "Create a default UNIX user account" step never completed. +// +// Uses a `reg query` fallback strategy because some hosts (e.g. Electron +// spawning PowerShell with certain user profiles) return nothing from the +// PowerShell registry provider; parsing `reg query` output is ugly but +// native Windows and always available. export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise { - const script = [ - "$ErrorActionPreference = 'Stop'", - "$out = @()", - "Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' -ErrorAction SilentlyContinue | ForEach-Object {", - " $name = $_.GetValue('DistributionName')", - " if (-not $name) { return }", - " $out += [PSCustomObject]@{", - " name = $name", - " defaultUid = [int]$_.GetValue('DefaultUid', 0)", - " state = [int]$_.GetValue('State', 0)", - " version = [int]$_.GetValue('Version', 0)", - " }", - "}", - "$out | ConvertTo-Json -Compress", - ].join("; ") - const result = await runPowerShell(script, opts) - if (result.code !== 0) return [] - const text = result.stdout.trim() - if (!text) return [] - try { - const parsed = JSON.parse(text) as WslRegistryDistro | WslRegistryDistro[] - return Array.isArray(parsed) ? parsed : [parsed] - } catch { + // `reg query` prints each subkey's values in a stable format: + // + // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{guid} + // DistributionName REG_SZ Ubuntu-24.04 + // DefaultUid REG_DWORD 0x0 + // State REG_DWORD 0x1 + // Version REG_DWORD 0x2 + // ... + const result = await runCommand( + "reg.exe", + ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss", "/s"], + opts, + ) + const stdout = result.stdout + if (result.code !== 0 || !stdout) { + ;(opts?.onLine ?? (() => undefined))({ + stream: "stderr", + text: `reg query failed code=${result.code} stderr=${result.stderr.slice(0, 200)}`, + }) return [] } + const blocks = stdout.split(/\r?\n\r?\n/) + const out: WslRegistryDistro[] = [] + for (const block of blocks) { + const header = block.match(/^(HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\\{[^}]+\})/i) + if (!header) continue + const name = block.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/m)?.[1] + if (!name) continue + const uidHex = block.match(/^\s+DefaultUid\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" + const stateHex = block.match(/^\s+State\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" + const versionHex = block.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" + out.push({ + name, + defaultUid: Number.parseInt(uidHex, 16), + state: Number.parseInt(stateHex, 16), + version: Number.parseInt(versionHex, 16), + }) + } + return out } export type WslFirstRunCheck = From f347d9a58afdfcc99c98c3cd8f9327fca0a26812 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:49:38 +1000 Subject: [PATCH 59/88] fix(desktop-wsl): drop DefaultUid=0 first-run gate, --user root bypasses OOBE Verified with live probes: 'wsl -d Ubuntu-24.04 --user root -- echo OK' succeeds on a distro whose registry DefaultUid is still 0 (OOBE never ran). The prior hang symptom was from invoking wsl WITHOUT --user when DefaultUid=0, which triggers the interactive 'Create a default UNIX user account' prompt. With --user root explicit in wslArgs the OOBE hook is skipped and resolveWslOpencode finds /root/.opencode/bin/opencode etc. Remove the overly aggressive first-run gate from spawnWslSidecar; the registry probe helpers stay in wsl.ts for future diagnostic use. 20s timeout + failure logging remain as safety nets for true wsl.exe wedges. --- packages/desktop-electron/src/main/server.ts | 28 +++++++------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index cf2e5e34915a..8bfd19ef2029 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -5,7 +5,7 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" -import { checkWslDistroFirstRun, type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" +import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -83,23 +83,15 @@ export async function spawnWslSidecar( distro: string, opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { - // Gate on the registry state BEFORE any wsl.exe invocation. If the - // distro still has DefaultUid=0 it means the interactive first-run - // "Create a default UNIX user account" prompt never completed, and - // every wsl.exe -d ... call will silently block on stdin - // forever (we verified: Ubuntu-24.04 hangs on both -- echo and - // --user root -- echo in this state). Fail fast with a clear message - // so the controller can surface it to the user. - const firstRun = await checkWslDistroFirstRun(distro) - if (firstRun.status === "not-installed") { - throw new Error(`WSL distro ${distro} is not installed`) - } - if (firstRun.status === "needs-first-run") { - throw new Error( - `WSL distro ${distro} has not completed first-run setup. Open a terminal and run 'wsl -d ${distro}' to create a default UNIX user, then retry.`, - ) - } - + // Every wsl.exe invocation below goes through wslArgs which injects + // `--user root`. That matters even when a distro has DefaultUid=0 + // (i.e. the interactive first-run user account setup never ran): + // explicit --user root bypasses the OOBE hook that would otherwise + // prompt on stdin, so we can resolve opencode and spawn the sidecar + // without any machine-wide first-run handshake. The earlier Ubuntu + // hang was caused by invoking without --user (default uid 0 triggers + // OOBE), not by the registry state itself. We still have a 20s + // timeout in runCommand as a safety net for true wsl.exe wedges. const opencode = await resolveWslOpencode(distro) if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) From 3ad63536bf34eb574666dcb7872c21657f800dbc Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:13:55 +1000 Subject: [PATCH 60/88] chore: remove solid-js owner/cleanup instrumentation The instrumentation diagnosed the cleanNode reentry bug that's since been fixed (Terminal queueMicrotask, SortableWorkspace/LocalWorkspace undefined guards). Keeping it around imposes significant main-thread cost on busy subtrees: wraps every owner with accessor/Proxy traps, captures a fresh Error().stack on every owned mutation, and pushes to a ring buffer. On a ~40k-owner graph after a server switch this saturated the main thread and tripped Electron's unresponsive watchdog a few minutes in. Drop the file and its import. --- .../desktop-electron/src/renderer/index.tsx | 13 +- .../src/renderer/solid-instrument.ts | 275 ------------------ 2 files changed, 2 insertions(+), 286 deletions(-) delete mode 100644 packages/desktop-electron/src/renderer/solid-instrument.ts diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index e9b105aa3948..7aae90348572 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,18 +1,9 @@ // @refresh reload -// V8's default Error.stackTraceLimit truncates at 10 frames, which is exactly -// the depth of the recursive cleanNode crash — the real trigger (our code -// calling dispose, or a store update racing disposal) is beyond that. Raise -// it so stacks contain the origin frame. +// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so +// reported errors come with a useful frame budget. Error.stackTraceLimit = 200 -// Install the solid-js owner/cleanup instrumentation before anything else -// touches solid-js so every created owner gets accessor-based traps on its -// `owned` and `cleanups`. This logs the exact cleanup-cascade that nulls an -// owner's `owned` mid-iteration — the root cause of the recursive cleanNode -// crash. Debug-only; remove once the offending cleanup is identified. -import "./solid-instrument" - // Install global error listeners before any other module runs so that // uncaught errors and rejected promises reach the main process with their // full stacks intact. Electron's `console-message` event only forwards the diff --git a/packages/desktop-electron/src/renderer/solid-instrument.ts b/packages/desktop-electron/src/renderer/solid-instrument.ts deleted file mode 100644 index c4bf8e11ad22..000000000000 --- a/packages/desktop-electron/src/renderer/solid-instrument.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Debug-only instrumentation for the recursive cleanNode crash -// ("Cannot read properties of null (reading '1')" at node.owned[i]). -// -// The crash stack has ~150 pure cleanNode frames with no cleanup frame -// between them, so the null assignment doesn't happen live during the -// crashing call. Something earlier (an earlier cleanup or earlier cascade) -// nulled an owner's .owned while it was still referenced from another -// computation's owned list, and the later cleanNode recursion walks into it. -// -// To find that, we: -// 1. Install an accessor trap via DEV.hooks.afterCreateOwner that records -// every owned = mutation with a tag, a short stack, and -// whether a cleanup was currently running. Pushed into a ring buffer. -// 2. On any uncaught TypeError we dump the ring buffer to the console. -// 3. Attach a __solidTag to every owner so we can correlate. -// -// The module must be imported before anything else touches solid-js so the -// first owner created (the render root) is instrumented. - -import { DEV } from "solid-js" - -type CleanupEntry = { originFrames: string[]; runAtFrames: string[] } -type OwnedEvent = { - ts: number - ownerTag: number - action: "set-null" | "set-array" | "initial-null" - prevLen: number | null - nextLen: number | null - cleanupDepth: number - cleanNodeFramesAbove: number - cleanNodeFramesBelow: number - topCleanupOrigin: string[] | null - stackHead: string[] -} - -type OwnedAccess = { - ownerTag: number - prop: string - hit: boolean - ts: number -} - -declare global { - // eslint-disable-next-line no-var - var __SOLID_CLEANUP_STACK: CleanupEntry[] - // eslint-disable-next-line no-var - var __SOLID_OWNED_EVENTS: OwnedEvent[] - // eslint-disable-next-line no-var - var __SOLID_OWNERS_BY_TAG: Map - // eslint-disable-next-line no-var - var __SOLID_DUMP_DONE: boolean - // eslint-disable-next-line no-var - var __SOLID_LAST_OWNED_ACCESS: OwnedAccess | null - // eslint-disable-next-line no-var - var __SOLID_OWNED_ACCESS_LOG: OwnedAccess[] -} - -const RING_SIZE = 500 -const ACCESS_LOG_SIZE = 50 - -globalThis.__SOLID_CLEANUP_STACK = globalThis.__SOLID_CLEANUP_STACK ?? [] -globalThis.__SOLID_OWNED_EVENTS = globalThis.__SOLID_OWNED_EVENTS ?? [] -globalThis.__SOLID_OWNERS_BY_TAG = globalThis.__SOLID_OWNERS_BY_TAG ?? new Map() -globalThis.__SOLID_DUMP_DONE = false -globalThis.__SOLID_LAST_OWNED_ACCESS = null -globalThis.__SOLID_OWNED_ACCESS_LOG = [] - -const stackFrames = (err: Error, n = 30): string[] => { - const lines = (err.stack ?? "").split("\n") - return lines - .slice(1, 1 + n) - .map((l) => l.trim()) - .filter((l) => l.startsWith("at ")) -} - -const isCleanNodeFrame = (f: string) => f.startsWith("at cleanNode ") -const isWrappedCleanupFrame = (f: string) => f.includes("wrappedCleanup") - -const pushEvent = (ev: OwnedEvent) => { - const buf = globalThis.__SOLID_OWNED_EVENTS - buf.push(ev) - if (buf.length > RING_SIZE) buf.splice(0, buf.length - RING_SIZE) -} - -const wrapCleanup = (fn: Function, node: any): Function => { - const originFrames = stackFrames(new Error("onCleanup-site"), 20) - function wrappedCleanup(this: unknown, ...args: unknown[]) { - const entry: CleanupEntry = { - originFrames, - runAtFrames: stackFrames(new Error("cleanup-run"), 15), - } - globalThis.__SOLID_CLEANUP_STACK.push(entry) - try { - return fn.apply(this, args) - } finally { - globalThis.__SOLID_CLEANUP_STACK.pop() - } - } - ;(wrappedCleanup as any).__original = fn - ;(wrappedCleanup as any).__originFrames = originFrames - ;(wrappedCleanup as any).__ownerTag = node.__solidTag - return wrappedCleanup -} - -const wrapOwnedArray = (arr: any[], node: any): any[] => { - // Proxy the owned array so we can log every numeric-index read. - // cleanNode iterates via `node.owned[i]` — the crashing access is exactly - // such a read that returns an index on a null array (but our owned is - // always an array or null, never a null array access via this proxy). - // We log what the CURRENT iteration is reading so the crash handler can - // name the owner whose owned was just touched. - return new Proxy(arr, { - get(target, prop, recv) { - if (typeof prop === "string") { - const n = Number(prop) - if (Number.isInteger(n) && n >= 0) { - const entry: OwnedAccess = { - ownerTag: node.__solidTag, - prop, - hit: n in target, - ts: Date.now(), - } - globalThis.__SOLID_LAST_OWNED_ACCESS = entry - const log = globalThis.__SOLID_OWNED_ACCESS_LOG - log.push(entry) - if (log.length > ACCESS_LOG_SIZE) log.splice(0, log.length - ACCESS_LOG_SIZE) - } - } - return Reflect.get(target, prop, recv) - }, - }) -} - -const wrapCleanupsArray = (arr: any[], node: any): any[] => { - const existing = arr.slice() - arr.length = 0 - for (const fn of existing) Array.prototype.push.call(arr, wrapCleanup(fn, node)) - - const origPush = arr.push.bind(arr) - Object.defineProperty(arr, "push", { - configurable: true, - writable: true, - value: (...fns: Function[]) => { - const wrapped = fns.map((fn) => wrapCleanup(fn, node)) - return origPush(...wrapped) - }, - }) - return arr -} - -if (DEV?.hooks) { - let tagCounter = 0 - const prev = DEV.hooks.afterCreateOwner - DEV.hooks.afterCreateOwner = (node: any) => { - if (prev) prev(node) - if (node.__solidTag !== undefined) return - node.__solidTag = ++tagCounter - globalThis.__SOLID_OWNERS_BY_TAG.set(node.__solidTag, node) - try { - node.__createdAtFrames = stackFrames(new Error("owner-created"), 12) - } catch { - /* ignore */ - } - - let ownedValue: any[] | null = Array.isArray(node.owned) ? wrapOwnedArray(node.owned, node) : (node.owned ?? null) - Object.defineProperty(node, "owned", { - configurable: true, - enumerable: true, - get() { - return ownedValue - }, - set(v: any[] | null) { - const prevArr = ownedValue - if (Array.isArray(v)) v = wrapOwnedArray(v, node) - // Record every owned mutation so we can post-hoc trace corruption. - const frames = stackFrames(new Error("owned-set"), 30) - const wrappedIdx = frames.findIndex(isWrappedCleanupFrame) - const cleanupDepth = globalThis.__SOLID_CLEANUP_STACK.length - pushEvent({ - ts: Date.now(), - ownerTag: node.__solidTag, - action: prevArr == null && v == null ? "initial-null" : v == null ? "set-null" : "set-array", - prevLen: prevArr == null ? null : prevArr.length, - nextLen: v == null ? null : v.length, - cleanupDepth, - cleanNodeFramesAbove: wrappedIdx >= 0 ? frames.slice(wrappedIdx + 1).filter(isCleanNodeFrame).length : 0, - cleanNodeFramesBelow: - wrappedIdx >= 0 - ? frames.slice(0, wrappedIdx).filter(isCleanNodeFrame).length - : frames.filter(isCleanNodeFrame).length, - topCleanupOrigin: cleanupDepth > 0 ? globalThis.__SOLID_CLEANUP_STACK[cleanupDepth - 1]!.originFrames : null, - stackHead: frames.slice(0, 8), - }) - ownedValue = v - }, - }) - - let cleanupsValue: any[] | null = node.cleanups ?? null - if (Array.isArray(cleanupsValue)) cleanupsValue = wrapCleanupsArray(cleanupsValue, node) - Object.defineProperty(node, "cleanups", { - configurable: true, - enumerable: true, - get() { - return cleanupsValue - }, - set(v: any[] | null) { - if (Array.isArray(v)) { - cleanupsValue = wrapCleanupsArray(v, node) - } else { - cleanupsValue = v - } - }, - }) - } - try { - console.log("[solid-instrument] installed afterCreateOwner hook") - } catch { - /* ignore */ - } -} - -// When the cleanNode TypeError fires, dump everything we've recorded so we -// can see which owners had their .owned nulled in the moments just before -// the crash. Install at capture phase so we run before any other handler. -const dumpOwnedHistory = (label: string) => { - if (globalThis.__SOLID_DUMP_DONE) return - globalThis.__SOLID_DUMP_DONE = true - try { - const events = globalThis.__SOLID_OWNED_EVENTS - // Last 20 events + last 15 set-null events are plenty for correlation. - const tail = events.slice(-20) - const nulls = events.filter((e) => e.action === "set-null").slice(-15) - const lastAccess = globalThis.__SOLID_LAST_OWNED_ACCESS - const accessLog = globalThis.__SOLID_OWNED_ACCESS_LOG.slice(-25) - - // Pull the ownerTag the crash was iterating and see if that tag was - // set-null'd in the recent events ring. That is the smoking gun. - const suspectTag = lastAccess?.ownerTag - const suspectNull = - suspectTag != null ? events.filter((e) => e.ownerTag === suspectTag && e.action === "set-null") : [] - - console.error( - `[${label}] SUSPECT OWNER AT CRASH:`, - JSON.stringify( - { - lastOwnedAccess: lastAccess, - suspectTag, - suspectOwnerCreatedAt: - suspectTag != null ? globalThis.__SOLID_OWNERS_BY_TAG.get(suspectTag)?.__createdAtFrames : null, - suspectSetNullEvents: suspectNull, - }, - null, - 2, - ), - ) - console.error(`[${label}] last ${accessLog.length} owned[i] accesses:`, JSON.stringify(accessLog, null, 2)) - console.error(`[${label}] last ${nulls.length} set-null events:`, JSON.stringify(nulls, null, 2)) - console.error(`[${label}] last ${tail.length} owned mutation events:`, JSON.stringify(tail, null, 2)) - } catch (e) { - console.error(`[${label}] dump failed`, e) - } -} - -window.addEventListener( - "error", - (ev) => { - const msg = (ev.error && ev.error.message) || ev.message || "" - if (typeof msg === "string" && msg.includes("Cannot read properties of null")) { - dumpOwnedHistory("SOLID CLEANNODE CRASH") - } - }, - true, -) - -export {} From 3d26bbed825e34b0fd361b36bcb86473029ef862 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:03:41 +1000 Subject: [PATCH 61/88] clean maybe --- packages/app/src/app.tsx | 20 ++- .../app/src/components/dialog-wsl-server.tsx | 26 ++-- packages/desktop-electron/src/main/apps.ts | 17 +-- packages/desktop-electron/src/main/index.ts | 29 ++-- .../desktop-electron/src/main/wsl-servers.ts | 29 ++++ packages/desktop-electron/src/main/wsl.ts | 44 ++++++ packages/ui/src/components/dialog.tsx | 17 ++- packages/ui/src/context/dialog.tsx | 34 ++++- plan.md | 138 ------------------ 9 files changed, 167 insertions(+), 187 deletions(-) delete mode 100644 plan.md diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2ff68b9dd5f2..5528523ab999 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -256,9 +256,23 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: size="large" class="mt-4" onClick={() => { - void import("@/components/dialog-select-server").then((x) => { - dialog.show(() => ) - }) + void import("@/components/dialog-select-server") + .then((x) => { + dialog.show(() => ( + { + // We're above the Router here so useNavigate() isn't available. + // Update the browser URL directly; after server.setActive fires + // ServerKey remounts the Router which picks up "/" on init. + // Harmless under MemoryRouter (Electron), which restarts at "/". + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } + }} + /> + )) + }) + .catch((err) => console.error("Failed to load server dialog", err)) }} > Manage servers diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index 49ac18023869..d971051eec50 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -2,7 +2,7 @@ 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, on, onCleanup, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { WslServerStep, WslServersState } from "@/context/platform" @@ -125,14 +125,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { 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()) - createEffect( - on(recommendedStep, (next) => { - setStore("step", next) - }), - ) - const autoProbe = createMemo(() => { const state = current() if (!state || !wslServers() || busy()) return null @@ -157,8 +154,19 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { createEffect(() => { const probe = autoProbe() if (!probe || probe.key === lastAutoProbe) return - lastAutoProbe = probe.key - void run(probe.run) + const key = probe.key + lastAutoProbe = key + void (async () => { + try { + await probe.run() + } catch (err) { + // 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) + } + })() }) createEffect(() => { diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index d0fd835b8e6d..eb0b260ea96e 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readFileSync, readdirSync } from "node:fs" import { dirname, extname, join } from "node:path" -import { wslArgs } from "./wsl" +import { resolveWslHome, runWslInDistro } from "./wsl" export function checkAppExists(appName: string): boolean { if (process.platform === "win32") return true @@ -14,20 +14,17 @@ export function resolveAppPath(appName: string): string | null { return resolveWindowsAppPath(appName) } -export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): string { +export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { 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", wslArgs(["sh", "-lc", cmd], distro)) - return output.toString().trim() + const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path + const output = await runWslInDistro(["wslpath", flag, resolved], distro) + if (output.code !== 0) { + throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) } - - const output = execFileSync("wsl", wslArgs(["wslpath", flag, path], distro)) - return output.toString().trim() + return output.stdout.trim() } catch (error) { throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 95d688b2771b..73b6a644ead9 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -56,23 +56,20 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() void serverReady.promise.catch(() => undefined) -const wslServers = (() => { - const logger = initLogging() - return 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 logger = initLogging() +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), + }, +) logger.log("app starting", { version: app.getVersion(), diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index e60157fc3f78..c35e4e52bf51 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -57,6 +57,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() + const startAttempts = new Map() let jobAbort: AbortController | undefined const emit = () => { @@ -116,14 +117,38 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa updateServer(id, (item) => ({ ...item, runtime })) } + 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" }) mainLogger?.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", @@ -134,6 +159,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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 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 @@ -327,6 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa }, async removeServer(id: string) { + invalidateStartAttempt(id) await stopServerInternal(id) const remaining = readPersistedServers().filter((item) => item.id !== id) persistServers(remaining) @@ -336,6 +363,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa startServer, async stopServer(id: string) { + invalidateStartAttempt(id) await stopServerInternal(id) setRuntime(id, { kind: "stopped" }) }, @@ -350,6 +378,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa }, stopAll() { + for (const item of state.servers) invalidateStartAttempt(item.config.id) for (const [id] of sidecars) { const existing = sidecars.get(id) try { diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 54b23800205e..07a22b8252ab 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -353,11 +353,55 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } +async function readWslDefaultUser(distro: string, opts?: RunWslOptions) { + const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro) + if (!entry || entry.defaultUid === 0) return null + + const passwd = firstLine( + ( + await runWslSh( + [ + "if command -v getent >/dev/null 2>&1; then", + ` getent passwd ${entry.defaultUid}`, + "else", + ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`, + "fi", + ].join("\n"), + distro, + opts, + ) + ).stdout, + ) + if (!passwd) return null + + const parts = passwd.split(":") + const username = parts[0]?.trim() ?? "" + const home = parts[5]?.trim() ?? "" + if (!home) return null + return { username: username || null, home } +} + +export async function resolveWslHome(distro: string, opts?: RunWslOptions) { + return (await readWslDefaultUser(distro, opts))?.home ?? "/root" +} + +function opencodeCandidate(path: string) { + return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi` +} + export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) if (command && !command.startsWith("/mnt/")) return command + const home = await resolveWslHome(distro, opts) for (const candidate of [ + ...(home !== "/root" + ? [ + opencodeCandidate(`${home}/.local/bin/opencode`), + opencodeCandidate(`${home}/bin/opencode`), + opencodeCandidate(`${home}/.opencode/bin/opencode`), + ] + : []), 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 88f43178ca1f..39003f3b6825 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,6 +1,7 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" +import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" import { useI18n } from "../context/i18n" +import { DialogContext } from "../context/dialog" import { IconButton } from "./icon-button" export interface DialogProps extends ParentProps { @@ -12,11 +13,19 @@ export interface DialogProps extends ParentProps { classList?: ComponentProps<"div">["classList"] fit?: boolean transition?: boolean + // When `false`, clicking the overlay or outside the dialog will not dismiss it. + // Default is `true`. dismissOutside?: boolean } export function Dialog(props: DialogProps) { const i18n = useI18n() + const dialogCtx = useContext(DialogContext) + createEffect(() => { + if (!dialogCtx) return + if (props.dismissOutside === undefined) return + dialogCtx.active?.setDismissOutside(props.dismissOutside) + }) return (
{ - if (props.dismissOutside === false) e.preventDefault() - }} - onPointerDownOutside={(e) => { - if (props.dismissOutside === false) e.preventDefault() - }} onOpenAutoFocus={(e) => { const target = e.currentTarget as HTMLElement | null const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index b4d866768b6c..a39f5a0f3fc5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -23,10 +23,14 @@ type Active = { owner: Owner onClose?: () => void setClosing: (closing: boolean) => void + dismissOutside: () => boolean + setDismissOutside: (value: boolean) => void } const Context = createContext>() +export const DialogContext = Context + function init() { const [active, setActive] = createSignal() const timer = { current: undefined as ReturnType | undefined } @@ -89,12 +93,17 @@ function init() { const id = Math.random().toString(36).slice(2) let dispose: (() => void) | undefined let setClosing: ((closing: boolean) => void) | undefined + let setDismissOutsideSignal: ((value: boolean) => void) | undefined + let dismissOutsideAccessor: (() => boolean) | undefined const node = runWithOwner(owner, () => createRoot((d: () => void) => { dispose = d const [closing, setClosingSignal] = createSignal(false) setClosing = setClosingSignal + const [dismissOutside, setDismissOutside] = createSignal(true) + dismissOutsideAccessor = dismissOutside + setDismissOutsideSignal = setDismissOutside return ( - + { + if (dismissOutside()) close() + }} + /> {element()} @@ -113,9 +127,18 @@ function init() { }), ) - if (!dispose || !setClosing) return - - setActive({ id, node, dispose, owner, onClose, setClosing }) + if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return + + setActive({ + id, + node, + dispose, + owner, + onClose, + setClosing, + dismissOutside: dismissOutsideAccessor, + setDismissOutside: setDismissOutsideSignal, + }) } return { @@ -159,5 +182,8 @@ export function useDialog() { close() { ctx.close() }, + setDismissOutside(value: boolean) { + ctx.active?.setDismissOutside(value) + }, } } diff --git a/plan.md b/plan.md deleted file mode 100644 index 4862eed89afc..000000000000 --- a/plan.md +++ /dev/null @@ -1,138 +0,0 @@ -# WSL Server Implementation Backlog - -This backlog assumes Electron only. -It is ordered chronologically. -Each task is intended to be small enough for a new engineer to pick up directly. - -## Direction - -- Local Server is **always** Windows-native local on Windows. -- Local Server has no runtime swap and no WSL mode. -- WSL servers are a **separate, additive** concept. -- A Windows user can add zero or more WSL servers; each is bound to a specific distro. -- Each WSL server runs as its own sidecar alongside the Windows Local Server. -- Adding/removing a WSL server is hot; no app restart required. -- Manage Servers UI exposes an `Add WSL` button (Windows only) that opens the wizard. - -## 01 Electron Config Split - -- [x] Remove runtime `mode` / `distro` from the persisted Local Server config. -- [x] Introduce a new persisted `wslServers` key holding an array of `WslServerConfig`. -- [x] Define `WslServerConfig` as `{ id, distro, onboarding, acknowledgements }`. -- [x] Keep onboarding metadata per WSL server, not globally. -- [x] Keep acknowledgement state per WSL server. -- [x] Migrate any legacy `localServer.mode === "wsl"` entry into a single `wslServers` entry on read. -- [x] Drop all references to `LocalServerMode` from the preload types. - -## 02 Main-Process Multi-Sidecar Startup - -- [x] Always start the Windows local sidecar on app launch. -- [x] After Windows local is spawned, iterate each `WslServerConfig` and spawn a WSL sidecar per entry. -- [x] Give each WSL sidecar its own port and password. -- [x] Allocate the Windows local port/password once per launch (unchanged). -- [x] Track all sidecars in a single map keyed by server id. -- [x] Kill all sidecars on `before-quit` / `will-quit` / signal. -- [x] Include Windows local data in the startup payload unchanged. -- [x] Include the initial set of WSL servers (with url/password/status) in the startup payload. -- [x] Emit per-WSL-server lifecycle events (`starting`, `ready`, `failed`, `stopping`, `removed`). -- [x] On startup, do not block the main window on WSL sidecar health. -- [x] If a WSL sidecar fails health, keep it in the list and mark it failed instead of hanging startup. - -## 03 WSL Server Controller - -- [x] Create a main-process controller that owns `wslServers` persistence and runtime state. -- [x] Expose typed events (`state`, per-item status changes) from the controller. -- [x] Support one in-flight job per WSL server (not a global in-flight job). -- [x] Implement `addServer(distro)` that persists, then spawns and health-checks a new sidecar. -- [x] Implement `removeServer(id)` that stops the sidecar and removes it from config. -- [x] Implement per-server `runStep`, `cancelJob`, `installWsl`, `installDistro`, `installOpencode`, `openTerminal`. -- [x] Reuse the existing WSL process helpers unchanged. -- [x] Keep transcripts per server, only for the current app launch. - -## 04 IPC / Preload Surface - -- [x] Rename `localServer.*` IPC channels to `wslServers.*`. -- [x] Add `wslServers.getState()` returning the full list plus per-server runtime info. -- [x] Add `wslServers.subscribe()` with unsubscribe support. -- [x] Add `wslServers.add(distro)` (persists config + starts sidecar). -- [x] Add `wslServers.remove(id)`. -- [x] Add `wslServers.runStep(id, step)`. -- [x] Add `wslServers.cancelJob(id)`. -- [x] Add `wslServers.installWsl(id)`. -- [x] Add `wslServers.installDistro(id, distro)`. -- [x] Add `wslServers.installOpencode(id)`. -- [x] Add `wslServers.openTerminal(id)`. -- [x] Remove obsolete `localServer.setConfig` / `localServer.*` channels. -- [x] Include url/username/password for each WSL server in the state payload (after sidecar start). - -## 05 Renderer Platform Wiring - -- [x] Expose `platform.wslServers` as a reactive accessor (list + subscribe). -- [x] Remove `platform.localServer` runtime swap APIs. -- [x] Keep `platform.wslServers` API available on Windows only. -- [x] Keep distro-aware path conversion keyed by the active WSL server. -- [x] When the active server is a WSL sidecar, default pickers to that distro's home. -- [x] When the active server is the Windows Local Server, keep native Windows picker defaults. - -## 06 Renderer Server List - -- [x] Always include the Windows Local Server in the server list. -- [x] Include each configured WSL server in the server list with `ServerConnection.Sidecar` variant `wsl`. -- [x] Keep `ServerConnection.key` returning `local:windows` for the Windows Local Server. -- [x] Keep `ServerConnection.key` returning `wsl:` for WSL servers (one per distro). -- [x] Keep distinct `projectsKey` buckets for Windows local vs each WSL server. -- [x] Do not collapse a WSL server into the `local` projects bucket. - -## 07 Manage Servers UI - -- [x] Remove `Swap to WSL` and `Swap to Windows` buttons from the Local Server row. -- [x] Show the Local Server row exactly like any other server (health, name, active check). -- [x] Add an `Add WSL` button next to `Add server`, visible only on Windows when the platform supports WSL. -- [x] `Add WSL` opens the same wizard stepper, scoped to a new WSL server draft. -- [x] Each WSL server row behaves like a sidecar entry (selectable, default-able, removable). -- [x] Add a `Remove` action to the WSL server row menu. -- [x] Add a `Retry setup` action when a WSL server is unhealthy. - -## 08 Add WSL Wizard - -- [x] Replace the "Switch" step with a `Done` step. -- [x] Step order becomes `WSL -> Distro -> OpenCode -> Done`. -- [x] On `Done`, persist the new WSL server, start the sidecar, and close the dialog. -- [x] If the user cancels, do not persist anything. -- [x] Allow resuming an incomplete WSL server wizard from Manage Servers. -- [x] Remove restart-to-apply copy, restart toasts, and "Use Windows" CTA. -- [x] Keep failure-only diagnostics panel behavior. - -## 09 Per-WSL Onboarding State - -- [x] Keep `WslServerConfig.onboarding` per server. -- [x] Keep `WslServerConfig.acknowledgements` per server. -- [x] Resume the wizard for any server where `onboarding.complete === false`. -- [x] Mark onboarding complete only after the sidecar becomes healthy. - -## 10 Connection Error Path - -- [x] If the active server is a WSL sidecar and health fails, offer `Open setup` that deep-links into the wizard for that server. -- [x] Keep behavior unchanged for the Windows Local Server. -- [x] Keep behavior unchanged for remote HTTP servers. - -## 11 Legacy Removal - -- [x] Remove `Swap to WSL` / `Swap to Windows` popover components. -- [x] Remove `restart-to-apply` banner copy and helpers from `dialog-local-server.tsx`. -- [x] Remove legacy `wslEnabled` preload shims. -- [x] Remove the old `DialogSelectServer` `initialTargetMode` prop. -- [x] Drop `localServerKey(config)` distinguishing `wsl` vs `windows` in the controller (local is always Windows). - -## 12 Verification - -- [ ] Verify the Windows Local Server starts unchanged on app launch. -- [ ] Verify `Add WSL` opens the wizard with the default installed distro preselected. -- [ ] Verify adding a WSL server spawns a new sidecar without restarting the app. -- [ ] Verify removing a WSL server stops the sidecar and removes it from the list. -- [ ] Verify multiple WSL servers can coexist, one per distro. -- [ ] Verify Windows Local Server stays active while WSL sidecars come and go. -- [ ] Verify a failed WSL sidecar does not block app startup or window creation. -- [ ] Verify the `ConnectionError` deep-link reaches the right wizard scope. -- [ ] Verify legacy `localServer.mode === "wsl"` persisted config migrates into `wslServers` on first launch. -- [ ] Verify project histories stay separate per server key. From e3d2a9ddbbc783ce3c2bb842f3fa16cc46d7d338 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:14:01 +1000 Subject: [PATCH 62/88] clean? --- packages/desktop-electron/src/main/index.ts | 17 +++++++++++------ packages/desktop-electron/src/main/ipc.ts | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 73b6a644ead9..4ee71ba2a5cc 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -325,17 +325,13 @@ function wireMenu() { void checkForUpdates(true) }, reload: () => mainWindow?.reload(), - relaunch: () => { - killSidecar() - wslServers.stopAll() - app.relaunch() - app.exit(0) - }, + relaunch: () => relaunchApp(), }) } registerIpcHandlers({ killSidecar: () => killSidecar(), + relaunch: () => relaunchApp(), awaitInitialization: async (sendStep) => { sendStep(initStep) const listener = (step: InitStep) => sendStep(step) @@ -386,6 +382,15 @@ function killSidecar() { server = null } +function relaunchApp() { + // app.exit() skips before-quit / will-quit, so relaunch callers must + // explicitly stop sidecars here rather than relying on process hooks. + killSidecar() + wslServers.stopAll() + app.relaunch() + app.exit(0) +} + 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 5134866ba42b..c6d2c4face98 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -22,6 +22,7 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void + relaunch: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getWslServersState: () => Promise | WslServersState onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void @@ -227,8 +228,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()) From bc84698428f88106bb4e4a90d6f85df2b99f5e77 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:09:49 +1000 Subject: [PATCH 63/88] feedback n stuff? --- diff.txt | 4594 +++++++++++++++++ .../src/components/dialog-select-server.tsx | 149 +- .../app/src/components/dialog-wsl-server.tsx | 66 +- .../app/src/components/server/server-row.tsx | 21 +- .../src/components/status-popover-body.tsx | 24 +- packages/app/src/components/terminal.tsx | 114 +- packages/app/src/context/global-sdk.tsx | 4 +- packages/app/src/context/server.tsx | 18 +- packages/app/src/context/terminal.test.ts | 7 +- packages/app/src/context/terminal.tsx | 158 +- packages/app/src/utils/server-health.ts | 32 +- packages/desktop-electron/src/main/apps.ts | 29 +- packages/desktop-electron/src/main/index.ts | 88 +- packages/desktop-electron/src/main/ipc.ts | 37 + packages/desktop-electron/src/main/migrate.ts | 3 +- packages/desktop-electron/src/main/server.ts | 50 +- packages/desktop-electron/src/main/store.ts | 10 +- packages/desktop-electron/src/main/wsl-pty.ts | 126 + .../desktop-electron/src/main/wsl-servers.ts | 91 +- packages/desktop-electron/src/main/wsl.ts | 61 +- .../desktop-electron/src/preload/index.ts | 1 + .../desktop-electron/src/preload/types.ts | 12 + .../desktop-electron/src/renderer/index.tsx | 93 +- packages/desktop/src-tauri/src/server.rs | 15 +- 24 files changed, 5660 insertions(+), 143 deletions(-) create mode 100644 diff.txt create mode 100644 packages/desktop-electron/src/main/wsl-pty.ts diff --git a/diff.txt b/diff.txt new file mode 100644 index 000000000000..351288c8f623 --- /dev/null +++ b/diff.txt @@ -0,0 +1,4594 @@ +diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx +index dbe1074484..5528523ab9 100644 +--- a/packages/app/src/app.tsx ++++ b/packages/app/src/app.tsx +@@ -1,5 +1,7 @@ + import "@/index.css" ++import { Button } from "@opencode-ai/ui/button" + import { I18nProvider } from "@opencode-ai/ui/context" ++import { useDialog } from "@opencode-ai/ui/context/dialog" + import { DialogProvider } from "@opencode-ai/ui/context/dialog" + import { FileComponentProvider } from "@opencode-ai/ui/context/file" + import { MarkedProvider } from "@opencode-ai/ui/context/marked" +@@ -26,6 +28,7 @@ import { + Suspense, + } from "solid-js" + import { Dynamic } from "solid-js/web" ++import { serverSwitching } from "@/utils/server-switch" + import { CommandProvider } from "@/context/command" + import { CommentsProvider } from "@/context/comments" + import { FileProvider } from "@/context/file" +@@ -37,6 +40,7 @@ import { LayoutProvider } from "@/context/layout" + import { ModelsProvider } from "@/context/models" + import { NotificationProvider } from "@/context/notification" + import { PermissionProvider } from "@/context/permission" ++import { usePlatform } from "@/context/platform" + import { PromptProvider } from "@/context/prompt" + import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" + import { SettingsProvider } from "@/context/settings" +@@ -73,7 +77,7 @@ declare global { + __OPENCODE__?: { + updaterEnabled?: boolean + deepLinks?: string[] +- wsl?: boolean ++ activeServer?: string + } + api?: { + setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise +@@ -223,12 +227,15 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { + } + + function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { ++ const dialog = useDialog() + const language = useLanguage() ++ const platform = usePlatform() + const server = useServer() + const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) + const name = createMemo(() => server.name || server.key) + const serverToken = "\u0000server\u0000" + const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) ++ const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") + + const timer = setInterval(() => props.onRetry?.(), 1000) + onCleanup(() => clearInterval(timer)) +@@ -243,6 +250,34 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: + {unreachable()[1]} +

+

{language.t("app.server.retrying")}

++ ++ ++ +
+ 0}> +
+@@ -285,6 +320,12 @@ export function AppInterface(props: { + router?: Component + disableHealthCheck?: boolean + }) { ++ // ServerKey wraps the whole Router so that switching `server.key` throws ++ // away any session / pty state from the previous server. Preserving the ++ // route across servers doesn't work because session ids, pty ids, and ++ // most URL-addressable resources are server-scoped — you'd 404 on every ++ // fetch. The click handler that swaps servers also navigates back to "/" ++ // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL. + return ( + + ++ ++
++ ++
++
+ + + +diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx +index dd92edec3e..93eaf0df49 100644 +--- a/packages/app/src/components/dialog-select-server.tsx ++++ b/packages/app/src/components/dialog-select-server.tsx +@@ -8,9 +8,9 @@ import { List } from "@opencode-ai/ui/list" + import { TextField } from "@opencode-ai/ui/text-field" + import { useMutation } from "@tanstack/solid-query" + import { showToast } from "@opencode-ai/ui/toast" +-import { useNavigate } from "@solidjs/router" +-import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" ++import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" + import { createStore, reconcile } from "solid-js/store" ++import { DialogWslServer } from "@/components/dialog-wsl-server" + import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" + import { useLanguage } from "@/context/language" + import { usePlatform } from "@/context/platform" +@@ -19,6 +19,11 @@ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" + + const DEFAULT_USERNAME = "opencode" + ++interface DialogSelectServerProps { ++ initialView?: "list" | "add-wsl" ++ onNavigateHome?: () => void ++} ++ + interface ServerFormProps { + value: string + name: string +@@ -171,8 +176,7 @@ function ServerForm(props: ServerFormProps) { + ) + } + +-export function DialogSelectServer() { +- const navigate = useNavigate() ++export function DialogSelectServer(props: DialogSelectServerProps = {}) { + const dialog = useDialog() + const server = useServer() + const platform = usePlatform() +@@ -191,6 +195,9 @@ export function DialogSelectServer() { + showForm: false, + status: undefined as boolean | undefined, + }, ++ addWsl: { ++ showWizard: props.initialView === "add-wsl", ++ }, + editServer: { + id: undefined as string | undefined, + value: "", +@@ -354,11 +361,13 @@ export function DialogSelectServer() { + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) +- navigate("/") ++ props.onNavigateHome?.() + return + } +- navigate("/") +- queueMicrotask(() => server.setActive(ServerConnection.key(conn))) ++ batch(() => { ++ props.onNavigateHome?.() ++ server.setActive(ServerConnection.key(conn)) ++ }) + } + + const handleAddChange = (value: string) => { +@@ -419,7 +428,8 @@ export function DialogSelectServer() { + ) + } + +- const mode = createMemo<"list" | "add" | "edit">(() => { ++ const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => { ++ if (store.addWsl.showWizard) return "add-wsl" + if (store.editServer.id) return "edit" + if (store.addServer.showForm) return "add" + return "list" +@@ -433,9 +443,11 @@ export function DialogSelectServer() { + const resetForm = () => { + resetAdd() + resetEdit() ++ setStore("addWsl", "showWizard", false) + } + + const startAdd = () => { ++ setStore("addWsl", "showWizard", false) + resetEdit() + setStore("addServer", { + showForm: true, +@@ -449,6 +461,7 @@ export function DialogSelectServer() { + } + + const startEdit = (conn: ServerConnection.Http) => { ++ setStore("addWsl", "showWizard", false) + resetAdd() + setStore("editServer", { + id: conn.http.url, +@@ -461,6 +474,12 @@ export function DialogSelectServer() { + }) + } + ++ const startAddWsl = () => { ++ resetAdd() ++ resetEdit() ++ setStore("addWsl", "showWizard", true) ++ } ++ + const submitForm = () => { + if (mode() === "add") { + if (addMutation.isPending) return +@@ -477,14 +496,22 @@ export function DialogSelectServer() { + + const isFormMode = createMemo(() => mode() !== "list") + const isAddMode = createMemo(() => mode() === "add") ++ const isAddWslMode = createMemo(() => mode() === "add-wsl") + const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) ++ const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows") + + const formTitle = createMemo(() => { + if (!isFormMode()) return language.t("dialog.server.title") + return ( +
+ +- {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} ++ ++ {isAddWslMode() ++ ? "Add WSL server" ++ : isAddMode() ++ ? language.t("dialog.server.add.title") ++ : language.t("dialog.server.edit.title")} ++ +
+ ) + }) +@@ -495,35 +522,65 @@ export function DialogSelectServer() { + resetEdit() + }) + +- async function handleRemove(url: ServerConnection.Key) { +- server.remove(url) +- if ((await platform.getDefaultServer?.()) === url) { ++ async function handleRemove(key: ServerConnection.Key) { ++ server.remove(key) ++ if ((await platform.getDefaultServer?.()) === key) { + void platform.setDefaultServer?.(null) + } + } + ++ async function handleRemoveWsl(conn: ServerConnection.Any) { ++ if (conn.type !== "sidecar" || conn.variant !== "wsl") return ++ const key = ServerConnection.key(conn) ++ try { ++ await platform.wslServers?.removeServer(key) ++ server.remove(key) ++ if ((await platform.getDefaultServer?.()) === key) { ++ void platform.setDefaultServer?.(null) ++ } ++ } catch (err) { ++ showRequestError(language, err) ++ } ++ } ++ ++ async function handleRetryWsl(conn: ServerConnection.Any) { ++ if (conn.type !== "sidecar" || conn.variant !== "wsl") return ++ try { ++ await platform.wslServers?.startServer(ServerConnection.key(conn)) ++ } catch (err) { ++ showRequestError(language, err) ++ } ++ } ++ + return ( +- ++ +
+ ++ ++ } ++ > ++ ++ + } + > + x.http.url} ++ key={(x) => ServerConnection.key(x)} + onSelect={(x) => { + if (x) void select(x) + }} +@@ -543,6 +600,7 @@ export function DialogSelectServer() { + > + {(i) => { + const key = ServerConnection.key(i) ++ const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" + return ( +
+
+@@ -562,12 +620,12 @@ export function DialogSelectServer() { + } + showCredentials + /> +-
++
+ + + + +- ++ + + + + +- { +- if (i.type !== "http") return +- startEdit(i) +- }} +- > +- {language.t("dialog.server.menu.edit")} +- +- ++ ++ { ++ if (i.type !== "http") return ++ startEdit(i) ++ }} ++ > ++ {language.t("dialog.server.menu.edit")} ++ ++ ++ ++ void handleRetryWsl(i)}> ++ Retry start ++ ++ ++ + setDefault(key)}> + + {language.t("dialog.server.menu.default")} + + + +- ++ + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + +- +- handleRemove(ServerConnection.key(i))} +- class="text-text-on-critical-base hover:bg-surface-critical-weak" +- > +- {language.t("dialog.server.menu.delete")} +- ++ ++ ++ (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} ++ class="text-text-on-critical-base hover:bg-surface-critical-weak" ++ > ++ ++ {language.t("dialog.server.menu.delete")} ++ ++ ++ + + + +@@ -621,17 +690,32 @@ export function DialogSelectServer() { + +
+ +- {language.t("dialog.server.add.button")} +- ++ ++
++ ++ ++ ++ ++
++
+ } + > + ++ )} ++ ++
++ ++ ++ ++
++
++
WSL
++ ++ ++ ++
++
{wslMessage()}
++ ++
++
Windows restart required.
++ ++
++
++
++
++ ++ ++
++
Choose a distro
++
{distroMessage()}
++ ++
++ 0} ++ fallback={ ++
++ {current()?.installed.length ++ ? "All installed distros are already added." ++ : current()?.runtime?.available ++ ? "No distros detected yet." ++ : "Checking distros..."} ++
++ } ++ > ++ ++ {(item) => ( ++ ++ )} ++ ++
++
++ ++ 0}> ++
++
++
Install
++ ++
++
++ ++ {(item) => { ++ const selected = () => store.installTarget === item.name ++ return ( ++ ++ ) ++ }} ++ ++
++
++
++ ++ ++
++ ++
WSL 2 is required.
++
++ ++ {(message) =>
{message()}
} ++
++ ++
This distro needs bash and curl.
++
++ ++
++ This distro is using the root user right now. ++
++
++
++
++ ++ ++
++
++ ++ ++
++
++
OpenCode
++ ++ ++ ++
++
{opencodeMessage()}
++ ++ {(check) => ( ++
++
Path: {check().resolvedPath ?? "not found"}
++
++ Version: {check().version ?? "unknown"} ++ ++ {(expected) => {` · desktop ${expected()}`}} ++ ++
++
++ Installed version does not match the desktop app version. ++
++
++ )} ++
++
++
++
++ ++ ++ {(progress) => ( ++
++
++ ++
Progress
++
++
{progress().title}
++
++ ++ {(line) => ( ++
++ {line.text} ++
++ )} ++
++
++
++ )} ++
++ ++ 0}> ++
++
Diagnostics
++
++ {(line) =>
{line.text}
}
++
++
++
++ ++
++ ++ ++
++
++
++ ) ++} ++ ++function requestError(language: ReturnType, 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), ++ }) ++} ++ ++function stepIndex(step: WslServerStep) { ++ return STEPS.indexOf(step) ++} ++ ++function stepTitle(step: WslServerStep) { ++ if (step === "wsl") return "WSL" ++ if (step === "distro") return "Choose distro" ++ return "OpenCode" ++} ++ ++function stepState( ++ step: WslServerStep, ++ state: { ++ active: WslServerStep ++ wslReady: boolean ++ distroReady: boolean ++ opencodeReady: boolean ++ opencodeMismatch: boolean ++ }, ++) { ++ if (state.active === step) return "current" ++ if (step === "wsl") return state.wslReady ? "done" : "warning" ++ if (step === "distro") ++ return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" ++ return state.opencodeMismatch ++ ? "warning" ++ : state.opencodeReady ++ ? "done" ++ : stepIndex(step) > stepIndex(state.active) ++ ? "locked" ++ : "warning" ++} +diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx +index 021e5be67e..3b4bda7f27 100644 +--- a/packages/app/src/components/session/session-header.tsx ++++ b/packages/app/src/components/session/session-header.tsx +@@ -6,9 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button" + import { Keybind } from "@opencode-ai/ui/keybind" + import { Spinner } from "@opencode-ai/ui/spinner" + import { showToast } from "@opencode-ai/ui/toast" ++import { StatusPopover } from "../status-popover" + import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" + import { getFilename } from "@opencode-ai/shared/util/path" +-import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" ++import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" + import { createStore } from "solid-js/store" + import { Portal } from "solid-js/web" + import { useCommand } from "@/context/command" +@@ -24,7 +25,6 @@ import { useSessionLayout } from "@/pages/session/session-layout" + import { messageAgentColor } from "@/utils/agent" + import { decode64 } from "@/utils/base64" + import { Persist, persisted } from "@/utils/persist" +-import { StatusPopover } from "../status-popover" + + const OPEN_APPS = [ + "vscode", +@@ -129,6 +129,13 @@ const showRequestError = (language: ReturnType, err: unknown + }) + } + ++function titlebarMounts() { ++ return { ++ center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined, ++ right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined, ++ } ++} ++ + export function SessionHeader() { + const layout = useLayout() + const command = useCommand() +@@ -219,6 +226,7 @@ export function SessionHeader() { + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) ++ const [mounts, setMounts] = createStore(titlebarMounts()) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( +@@ -232,6 +240,19 @@ export function SessionHeader() { + messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), + ) + ++ const syncMounts = () => { ++ const next = titlebarMounts() ++ if (mounts.center === next.center && mounts.right === next.right) return ++ setMounts(next) ++ } ++ ++ onMount(() => { ++ syncMounts() ++ const observer = new MutationObserver(() => syncMounts()) ++ observer.observe(document.body, { childList: true, subtree: true }) ++ onCleanup(() => observer.disconnect()) ++ }) ++ + const selectApp = (app: OpenApp) => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) +@@ -269,12 +290,8 @@ export function SessionHeader() { + .catch((err: unknown) => showRequestError(language, err)) + } + +- const [centerMount, setCenterMount] = createSignal(null) +- const [rightMount, setRightMount] = createSignal(null) +- onMount(() => { +- setCenterMount(document.getElementById("opencode-titlebar-center")) +- setRightMount(document.getElementById("opencode-titlebar-right")) +- }) ++ const centerMount = createMemo(() => mounts.center) ++ const rightMount = createMemo(() => mounts.right) + + return ( + <> +diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx +index 0f6a1c1355..cad0b0673a 100644 +--- a/packages/app/src/components/status-popover-body.tsx ++++ b/packages/app/src/components/status-popover-body.tsx +@@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" + import { useMutation } from "@tanstack/solid-query" + import { showToast } from "@opencode-ai/ui/toast" + import { useNavigate } from "@solidjs/router" +-import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" ++import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" + import { createStore, reconcile } from "solid-js/store" + import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" + import { useLanguage } from "@/context/language" +@@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" + import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" + import { useSync } from "@/context/sync" + import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" ++import { setServerSwitching } from "@/utils/server-switch" + + const pollMs = 10_000 + +@@ -292,8 +293,26 @@ export function StatusPopoverBody(props: { shown: Accessor }) { + aria-disabled={blocked()} + onClick={() => { + if (blocked()) return +- navigate("/") +- queueMicrotask(() => server.setActive(key)) ++ // Paint a full-window splash BEFORE the heavy ++ // ServerKey remount so the user gets visual ++ // feedback during the multi-second synchronous ++ // dispose cascade (xterm + file-tree + providers). ++ // setTimeout(0) yields to the browser so the ++ // splash lands on screen before the cascade ++ // starts; a second setTimeout(0) after the batch ++ // waits for the new subtree to paint, then ++ // dismisses the splash. ++ setServerSwitching(true) ++ setTimeout(() => { ++ try { ++ batch(() => { ++ navigate("/") ++ server.setActive(key) ++ }) ++ } finally { ++ setTimeout(() => setServerSwitching(false), 0) ++ } ++ }, 0) + }} + > + +@@ -329,7 +348,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) { + const run = ++dialogRun + void import("./dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return +- dialog.show(() => , defaultServer.refresh) ++ dialog.show( ++ () => navigate("/")} />, ++ defaultServer.refresh, ++ ) + }) + }} + > +diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx +index 57e91d6d33..edbbd752c9 100644 +--- a/packages/app/src/components/terminal.tsx ++++ b/packages/app/src/components/terminal.tsx +@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" + import { usePlatform } from "@/context/platform" + import { useSDK } from "@/context/sdk" + import { useServer } from "@/context/server" +-import { monoFontFamily, useSettings } from "@/context/settings" ++import { terminalFontFamily, useSettings } from "@/context/settings" + import type { LocalPTY } from "@/context/terminal" + import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" + import { terminalWriter } from "@/utils/terminal-writer" +@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => { + }) + + createEffect(() => { +- const font = monoFontFamily(settings.appearance.font()) ++ const font = terminalFontFamily(settings.appearance.font()) + if (!term) return + setOptionIfSupported(term, "fontFamily", font) + scheduleFit() +@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => { + cols: restoreSize?.cols, + rows: restoreSize?.rows, + fontSize: 14, +- fontFamily: monoFontFamily(settings.appearance.font()), ++ fontFamily: terminalFontFamily(settings.appearance.font()), + allowTransparency: false, + convertEol: false, + theme: terminalColors(), +@@ -613,17 +613,30 @@ export const Terminal = (props: TerminalProps) => { + drop?.() + if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) + ++ // Defer finalize (persistTerminal + local cleanup()) to a microtask so ++ // that its synchronous store write inside `persistTerminal` — which ++ // flows through `props.onCleanup` -> `ops.update` -> `update()` in ++ // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does ++ // NOT run inside the outer solid cleanNode cascade. Running it ++ // synchronously mid-cascade races with solid's recursive owned ++ // iteration (readSignal on a stale memo re-enters updateComputation, ++ // which nulls an ancestor's owned while the outer loop is still ++ // iterating it) and crashes with "Cannot read properties of null ++ // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode. ++ // queueMicrotask runs after the current sync reactive flush, so the ++ // store write lands in a fresh tick. + const finalize = () => { + persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) + cleanup() + } ++ const schedule = () => queueMicrotask(finalize) + + if (!output) { +- finalize() ++ schedule() + return + } + +- output.flush(finalize) ++ output.flush(schedule) + }) + + return ( +diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts +index 6788e8cc59..2d138e72f5 100644 +--- a/packages/app/src/context/global-sync/child-store.ts ++++ b/packages/app/src/context/global-sync/child-store.ts +@@ -96,8 +96,15 @@ export function createChildStoreManager(input: { + lifecycle.delete(directory) + const dispose = disposers.get(directory) + if (dispose) { +- dispose() + disposers.delete(directory) ++ // Defer the actual solid-js root disposal. When disposeDirectory runs ++ // from pinForOwner's onCleanup during a parent remount, calling ++ // dispose() here triggers a nested cleanNode cascade on the inner ++ // root while the outer cascade is mid-traversal, which corrupts ++ // solid-js's graph walk state and throws `Cannot read properties of ++ // null (reading '1')` at chunk-*.js:992. Running dispose on a ++ // microtask lets the outer cleanup finish first. ++ queueMicrotask(dispose) + } + delete children[directory] + input.onDispose(directory) +diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx +index 3bdc46391b..75e04a4a5b 100644 +--- a/packages/app/src/context/platform.tsx ++++ b/packages/app/src/context/platform.tsx +@@ -9,6 +9,111 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri + type SaveFilePickerOptions = { title?: string; defaultPath?: string } + type UpdateInfo = { updateAvailable: boolean; version?: string } + ++export type WslServerStep = "wsl" | "distro" | "opencode" ++ ++export type WslRuntimeCheck = { ++ available: boolean ++ version: string | null ++ status: string | null ++ error: string | null ++} ++export type WslInstalledDistro = { ++ name: string ++ state: string | null ++ version: number | null ++ isDefault: boolean ++} ++export type WslOnlineDistro = { ++ name: string ++ label: string ++} ++export type WslDistroProbe = { ++ name: string ++ canExecute: boolean ++ hasBash: boolean ++ hasCurl: boolean ++ username: string | null ++ isRoot: boolean | null ++ 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 WslTranscriptLine = { ++ stream: "stdout" | "stderr" | "system" ++ text: string ++ at: number ++} ++ ++export type WslServerAcknowledgements = { ++ root: boolean ++ mismatch: { path: string; version: string } | null ++} ++ ++export type WslServerConfig = { ++ id: string ++ distro: string ++ acknowledgements: WslServerAcknowledgements ++} ++ ++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 ++ opencodeChecks: Record ++ pendingRestart: boolean ++ servers: WslServerItem[] ++ job: WslJob | null ++ transcript: WslTranscriptLine[] ++ lastError: string | null ++} ++export type WslServersEvent = { type: "state"; state: WslServersState } ++ ++export type WslServersPlatform = { ++ getState(): Promise ++ subscribe(cb: (event: WslServersEvent) => void): () => void ++ probeRuntime(): Promise ++ refreshDistros(): Promise ++ installWsl(): Promise ++ installDistro(name: string): Promise ++ probeDistro(name: string): Promise ++ probeOpencode(name: string): Promise ++ installOpencode(name: string): Promise ++ openTerminal(name: string): Promise ++ addServer(distro: string): Promise ++ removeServer(id: string): Promise ++ startServer(id: string): Promise ++ stopServer(id: string): Promise ++ cancelJob(): Promise ++ updateAcknowledgements(id: string, acks: Partial): Promise ++} ++ + export type Platform = { + /** Platform discriminator */ + platform: "web" | "desktop" +@@ -64,11 +169,8 @@ export type Platform = { + /** Set the default server URL to use on app startup (platform-specific) */ + setDefaultServer?(url: ServerConnection.Key | null): Promise | void + +- /** Get the configured WSL integration (desktop only) */ +- getWslEnabled?(): Promise +- +- /** Set the configured WSL integration (desktop only) */ +- setWslEnabled?(config: boolean): Promise | void ++ /** Manage WSL sidecar servers (Electron on Windows only) */ ++ wslServers?: WslServersPlatform + + /** Get the preferred display backend (desktop only) */ + getDisplayBackend?(): Promise | DisplayBackend | null +diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx +index 9b666e5e75..0d1cee7107 100644 +--- a/packages/app/src/context/prompt.tsx ++++ b/packages/app/src/context/prompt.tsx +@@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( + const cache = new Map() + + const disposeAll = () => { +- for (const entry of cache.values()) { +- entry.dispose() +- } ++ // Defer the dispose calls to a microtask; synchronous nested dispose ++ // inside a parent onCleanup corrupts solid-js's in-flight cleanNode ++ // traversal during mass remounts (see context/terminal.tsx for the ++ // same pattern). ++ const pending = Array.from(cache.values(), (entry) => entry.dispose) + cache.clear() ++ if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) + } + + onCleanup(disposeAll) +diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx +index 1204fba557..096ef23db9 100644 +--- a/packages/app/src/context/server.tsx ++++ b/packages/app/src/context/server.tsx +@@ -23,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals + + function projectsKey(key: ServerConnection.Key) { + if (!key) return "" +- if (key === "sidecar") return "local" ++ if (key === "sidecar" || key === "local:windows") return "local" + if (isLocalHost(key)) return "local" + return key + } +@@ -81,7 +81,7 @@ export namespace ServerConnection { + return Key.make(conn.http.url) + case "sidecar": { + if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) +- return Key.make("sidecar") ++ return Key.make("local:windows") + } + case "ssh": + return Key.make(`ssh:${conn.host}`) +@@ -200,7 +200,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( + + const isReady = createMemo(() => ready() && !!state.active) + +- const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) ++ const check = (conn: ServerConnection.Any) => ++ checkServerHealth(conn.http).then((x) => { ++ if (!x.healthy) { ++ // Electron's console-message bridge only preserves the first ++ // console argument, so pre-stringify everything into one string. ++ console.warn( ++ `[server health] unhealthy key=${ServerConnection.key(conn)} url=${conn.http.url} hasAuth=${!!( ++ conn.http.username || conn.http.password ++ )}`, ++ ) ++ } ++ return x.healthy ++ }) + + createEffect(() => { + const current_ = current() +@@ -211,9 +223,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( + return + } + setState("healthy", undefined) ++ console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`) + onCleanup(startHealthPolling(current_)) + }) + ++ createEffect(() => { ++ const key = state.active ++ if (typeof window === "undefined") return ++ window.__OPENCODE__ ??= {} ++ window.__OPENCODE__.activeServer = key ++ }) ++ + const origin = createMemo(() => projectsKey(state.active)) + const projectsList = createMemo(() => store.projects[origin()] ?? []) + const current: Accessor = createMemo( +@@ -221,7 +241,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( + ) + const isLocal = createMemo(() => { + const c = current() +- return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) ++ return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) + }) + + return { +diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx +index a585789ce4..1534b173eb 100644 +--- a/packages/app/src/context/settings.tsx ++++ b/packages/app/src/context/settings.tsx +@@ -53,9 +53,13 @@ export const sansDefault = "System Sans" + + const monoFallback = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' ++const terminalMonoFallback = ++ '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono NFM", "JetBrainsMono NF", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono", "Hack Nerd Font", "MesloLGM Nerd Font Mono", "MesloLGM Nerd Font", "CaskaydiaCove NFM", "CaskaydiaCove Nerd Font Mono", "CaskaydiaMono Nerd Font Mono", ' + ++ monoFallback + const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' + + const monoBase = monoFallback ++const terminalMonoBase = terminalMonoFallback + const sansBase = sansFallback + + function input(font: string | undefined) { +@@ -85,6 +89,10 @@ export function monoFontFamily(font: string | undefined) { + return stack(font, monoBase) + } + ++export function terminalFontFamily(font: string | undefined) { ++ return stack(font, terminalMonoBase) ++} ++ + export function sansFontFamily(font: string | undefined) { + return stack(font, sansBase) + } +diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx +index 31d2d6e04c..482f55c716 100644 +--- a/packages/app/src/context/terminal.tsx ++++ b/packages/app/src/context/terminal.tsx +@@ -364,10 +364,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont + onCleanup(() => caches.delete(cache)) + + const disposeAll = () => { +- for (const entry of cache.values()) { +- entry.dispose() +- } ++ // Snapshot disposers, then defer them to a microtask. 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 ++ // solid-js's graph walk state and throwing `Cannot read properties of ++ // 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())) + } + + onCleanup(disposeAll) +diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts +index d80e9fffb0..4173cf9ca7 100644 +--- a/packages/app/src/index.ts ++++ b/packages/app/src/index.ts +@@ -1,7 +1,21 @@ + export { AppBaseProviders, AppInterface } from "./app" ++export { DialogWslServer } from "./components/dialog-wsl-server" + 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 Platform, PlatformProvider } from "./context/platform" ++export { ++ type DisplayBackend, ++ type Platform, ++ PlatformProvider, ++ type WslInstalledDistro, ++ type WslOnlineDistro, ++ type WslOpencodeCheck, ++ type WslServerConfig, ++ type WslServerItem, ++ type WslServersEvent, ++ type WslServersPlatform, ++ type WslServersState, ++ type WslServerStep, ++} from "./context/platform" + export { ServerConnection } from "./context/server" + export { handleNotificationClick } from "./utils/notification-click" +diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx +index 46cacdf627..b779ebd4f5 100644 +--- a/packages/app/src/pages/home.tsx ++++ b/packages/app/src/pages/home.tsx +@@ -75,7 +75,7 @@ export default function Home() { + size="large" + variant="ghost" + class="mt-4 mx-auto text-14-regular text-text-weak" +- onClick={() => dialog.show(() => )} ++ onClick={() => dialog.show(() => navigate("/")} />)} + > +
{ + if (dialogDead || dialogRun !== run) return +- dialog.show(() => ) ++ dialog.show(() => navigate("/")} />) + }) + } + +@@ -1840,7 +1847,7 @@ export default function Layout(props: ParentProps) { + ) + + function handleDragStart(event: unknown) { +- const id = getDraggableId(event) ++ const id = projectSortableWorktree(getDraggableId(event)) + if (!id) return + setHoverProject(undefined) + setStore("activeProject", id) +@@ -1849,11 +1856,14 @@ export default function Layout(props: ParentProps) { + function handleDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (draggable && droppable) { ++ const from = projectSortableWorktree(draggable.id?.toString()) ++ const to = projectSortableWorktree(droppable.id?.toString()) ++ if (!from || !to) return + const projects = layout.projects.list() +- const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) +- const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) ++ const fromIndex = projects.findIndex((p) => p.worktree === from) ++ const toIndex = projects.findIndex((p) => p.worktree === to) + if (fromIndex !== toIndex && toIndex !== -1) { +- layout.projects.move(draggable.id.toString(), toIndex) ++ layout.projects.move(from, toIndex) + } + } + } +@@ -1891,7 +1901,7 @@ export default function Layout(props: ParentProps) { + }) + + function handleWorkspaceDragStart(event: unknown) { +- const id = getDraggableId(event) ++ const id = workspaceSortableDirectory(getDraggableId(event)) + if (!id) return + setStore("activeWorkspace", id) + } +@@ -1899,13 +1909,16 @@ export default function Layout(props: ParentProps) { + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return ++ const from = workspaceSortableDirectory(draggable.id?.toString()) ++ const to = workspaceSortableDirectory(droppable.id?.toString()) ++ if (!from || !to) return + + const project = sidebarProject() + if (!project) return + + const ids = workspaceIds(project) +- const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) +- const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) ++ const fromIndex = ids.findIndex((dir) => dir === from) ++ const toIndex = ids.findIndex((dir) => dir === to) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + +@@ -2265,7 +2278,7 @@ export default function Layout(props: ParentProps) { + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > +- ++ + + {(directory) => ( + layout.projects.list() ++ const projectIds = createMemo(() => projects().map((project) => project.worktree)) + const projectOverlay = () => store.activeProject} /> + const sidebarContent = (mobile?: boolean) => ( + layout.sidebar.opened()} + aimMove={aim.move} + projects={projects} +- renderProject={(project) => ( +- +- )} ++ projectIds={projectIds} ++ renderProject={(worktree) => { ++ const project = createMemo(() => projects().find((item) => item.worktree === worktree)) ++ return ( ++ ++ {(project) => ( ++ ++ )} ++ ++ ) ++ }} + handleDragStart={handleDragStart} + handleDragEnd={handleDragEnd} + handleDragOver={handleDragOver} +diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx +index 076e1ef88b..d681cf3218 100644 +--- a/packages/app/src/pages/layout/sidebar-project.tsx ++++ b/packages/app/src/pages/layout/sidebar-project.tsx +@@ -34,6 +34,17 @@ export type ProjectSidebarContext = { + sessionProps: Omit + } + ++const PROJECT_SORTABLE_PREFIX = "project:" ++ ++export function projectSortableId(worktree: string) { ++ return `${PROJECT_SORTABLE_PREFIX}${worktree}` ++} ++ ++export function projectSortableWorktree(id: string | undefined) { ++ if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return ++ return id.slice(PROJECT_SORTABLE_PREFIX.length) ++} ++ + export const ProjectDragOverlay = (props: { + projects: Accessor + activeProject: Accessor +@@ -275,7 +286,7 @@ export const SortableProject = (props: { + }): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() +- const sortable = createSortable(props.project.worktree) ++ const sortable = createSortable(projectSortableId(props.project.worktree)) + const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) + const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) +diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx +index ca36af2a42..d9cd4d5a20 100644 +--- a/packages/app/src/pages/layout/sidebar-shell.tsx ++++ b/packages/app/src/pages/layout/sidebar-shell.tsx +@@ -11,13 +11,15 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" + import { IconButton } from "@opencode-ai/ui/icon-button" + import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" + import { type LocalProject } from "@/context/layout" ++import { projectSortableId } from "./sidebar-project" + + export const SidebarContent = (props: { + mobile?: boolean + opened: Accessor + aimMove: (event: MouseEvent) => void + projects: Accessor +- renderProject: (project: LocalProject) => JSX.Element ++ projectIds: Accessor ++ renderProject: (worktree: string) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void +@@ -63,8 +65,8 @@ export const SidebarContent = (props: { + + +
+- p.worktree)}> +- {(project) => props.renderProject(project)} ++ ++ {(worktree) => props.renderProject(worktree)} + + void + } + ++const WORKSPACE_SORTABLE_PREFIX = "workspace:" ++ ++export function workspaceSortableId(directory: string) { ++ return `${WORKSPACE_SORTABLE_PREFIX}${directory}` ++} ++ ++export function workspaceSortableDirectory(id: string | undefined) { ++ if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return ++ return id.slice(WORKSPACE_SORTABLE_PREFIX.length) ++} ++ + export const WorkspaceDragOverlay = (props: { + sidebarProject: Accessor + activeWorkspace: Accessor +@@ -300,7 +311,7 @@ export const SortableWorkspace = (props: { + const params = useParams() + const globalSync = useGlobalSync() + const language = useLanguage() +- const sortable = createSortable(props.directory) ++ const sortable = createSortable(workspaceSortableId(props.directory)) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) + const [menu, setMenu] = createStore({ + open: false, +@@ -308,12 +319,20 @@ export const SortableWorkspace = (props: { + }) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) +- const local = createMemo(() => props.directory === props.project.worktree) ++ // Guard against `props.project` being transiently undefined during a ++ // server-switch cascade. The parent renders ++ // {(dir) => } ++ // where `project()` can flip to undefined while the enclosing ++ // gate hasn't yet unmounted this child. Bootstrap's setStore can then fire ++ // these memos with stale props. ++ const local = createMemo(() => props.directory === (props.project?.worktree ?? "")) + const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) +- return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name ++ const projectId = props.project?.id ++ if (!projectId) return name ++ return props.ctx.workspaceName(props.directory, projectId, branch) ?? name + }) + const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) + const boot = createMemo(() => open() || active()) +@@ -344,7 +363,7 @@ export const SortableWorkspace = (props: { + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} +- projectId={props.project.id} ++ projectId={props.project?.id ?? ""} + /> + ) + +@@ -413,7 +432,7 @@ export const SortableWorkspace = (props: { + openEditor={props.ctx.openEditor} + showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} + showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} +- root={props.project.worktree} ++ root={props.project?.worktree ?? props.directory} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + navigateToNewSession={() => navigate(`/${slug()}/session`)} + /> +@@ -447,20 +466,33 @@ export const LocalWorkspace = (props: { + }): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() ++ // Same guard pattern as SortableWorkspace: the parent passes ++ // `project={project()!}` but `project()` can transiently flip to ++ // undefined during a server-switch cascade before this component ++ // unmounts, so every reactive memo reading props.project has to ++ // tolerate undefined. ++ const worktree = createMemo(() => props.project?.worktree ?? "") + const workspace = createMemo(() => { +- const [store, setStore] = globalSync.child(props.project.worktree) ++ const dir = worktree() ++ if (!dir) return undefined ++ const [store, setStore] = globalSync.child(dir) + return { store, setStore } + }) +- const slug = createMemo(() => base64Encode(props.project.worktree)) +- const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) +- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) ++ const slug = createMemo(() => (worktree() ? base64Encode(worktree()) : "")) ++ const sessions = createMemo(() => { ++ const store = workspace()?.store ++ return store ? sortedRootSessions(store, props.sortNow()) : [] ++ }) ++ const booted = createMemo((prev) => prev || workspace()?.store.status === "complete", false) + const count = createMemo(() => sessions()?.length ?? 0) +- const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) ++ const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) })) + const loading = createMemo(() => query.isPending && count() === 0) +- const hasMore = createMemo(() => workspace().store.sessionTotal > count()) ++ const hasMore = createMemo(() => (workspace()?.store.sessionTotal ?? 0) > count()) + const loadMore = async () => { +- workspace().setStore("limit", (limit) => (limit ?? 0) + 5) +- await globalSync.project.loadSessions(props.project.worktree) ++ const dir = worktree() ++ if (!dir) return ++ workspace()?.setStore("limit", (limit) => (limit ?? 0) + 5) ++ await globalSync.project.loadSessions(dir) + } + + return ( +diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts +index 0c6189dafe..26821134c8 100644 +--- a/packages/app/src/utils/scoped-cache.test.ts ++++ b/packages/app/src/utils/scoped-cache.test.ts +@@ -24,7 +24,7 @@ describe("createScopedCache", () => { + expect(disposed).toEqual(["b"]) + }) + +- test("disposes entries on delete and clear", () => { ++ test("disposes entries on delete and clear", async () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + dispose: (value) => disposed.push(value.key), +@@ -39,6 +39,9 @@ describe("createScopedCache", () => { + + cache.clear() + expect(cache.peek("b")).toBeUndefined() ++ // clear() defers dispose to a microtask to avoid nested cleanNode cascades ++ // when called from inside an onCleanup; flush the queue before asserting. ++ await Promise.resolve() + expect(disposed).toEqual(["a", "b"]) + }) + +diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts +index 224c363c1e..7044cdf03c 100644 +--- a/packages/app/src/utils/scoped-cache.ts ++++ b/packages/app/src/utils/scoped-cache.ts +@@ -89,10 +89,21 @@ export function createScopedCache(createValue: (key: string) => T, options: S + } + + const clear = () => { +- for (const [key, entry] of store) { +- dispose(key, entry) +- } ++ // Defer dispose() calls to a microtask. When clear() runs inside an ++ // onCleanup during a parent remount (e.g. context/file.tsx and ++ // context/comments.tsx both do this), synchronous dispose on cached ++ // createRoot entries starts a nested cleanNode cascade while the outer ++ // cascade is mid-traversal, corrupting solid-js's graph walk state and ++ // throwing `Cannot read properties of null (reading '1')` at ++ // chunk-*.js:992. Deferring lets the outer cleanup finish first. ++ const pending: Array<[string, Entry]> = [] ++ for (const entry of store) pending.push(entry) + store.clear() ++ if (pending.length && options.dispose) { ++ queueMicrotask(() => { ++ for (const [key, entry] of pending) dispose(key, entry) ++ }) ++ } + } + + return { +diff --git a/packages/app/src/utils/server-switch.tsx b/packages/app/src/utils/server-switch.tsx +new file mode 100644 +index 0000000000..480990b184 +--- /dev/null ++++ b/packages/app/src/utils/server-switch.tsx +@@ -0,0 +1,9 @@ ++import { createSignal } from "solid-js" ++ ++// Global flag used to paint a full-window splash overlay while a server ++// swap is in progress. ServerKey's keyed remount is a big ++// synchronous cascade (dispose + remount of the entire app subtree) that ++// can freeze the UI for several seconds; setting this true before the ++// swap and false after lets us render an overlay above the ServerKey ++// boundary so the freeze has visual feedback instead of looking stuck. ++export const [serverSwitching, setServerSwitching] = createSignal(false) +diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts +index d0e6c42b6c..267d6c6539 100644 +--- a/packages/desktop-electron/electron.vite.config.ts ++++ b/packages/desktop-electron/electron.vite.config.ts +@@ -60,6 +60,13 @@ export default defineConfig({ + plugins: [appPlugin], + publicDir: "../../../app/public", + root: "src/renderer", ++ server: { ++ host: "127.0.0.1", ++ strictPort: true, ++ hmr: { ++ host: "127.0.0.1", ++ }, ++ }, + define: { + "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), + }, +diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts +index 174da94a5d..eb0b260ea9 100644 +--- a/packages/desktop-electron/src/main/apps.ts ++++ b/packages/desktop-electron/src/main/apps.ts +@@ -1,6 +1,7 @@ + import { execFileSync } from "node:child_process" + import { existsSync, readFileSync, readdirSync } from "node:fs" + import { dirname, extname, join } from "node:path" ++import { resolveWslHome, runWslInDistro } from "./wsl" + + export function checkAppExists(appName: string): boolean { + if (process.platform === "win32") return true +@@ -13,20 +14,17 @@ export function resolveAppPath(appName: string): string | null { + return resolveWindowsAppPath(appName) + } + +-export function wslPath(path: string, mode: "windows" | "linux" | null): string { ++export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { + 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 resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path ++ const output = await runWslInDistro(["wslpath", flag, resolved], distro) ++ if (output.code !== 0) { ++ throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) + } +- +- const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) +- return output.toString().trim() ++ return output.stdout.trim() + } catch (error) { + throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) + } +diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts +index 1e21661c1a..9a6bb53c64 100644 +--- a/packages/desktop-electron/src/main/constants.ts ++++ b/packages/desktop-electron/src/main/constants.ts +@@ -6,5 +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 LEGACY_LOCAL_SERVER_KEY = "localServer" + export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" +diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts +index 946e01e325..87a87e7672 100644 +--- a/packages/desktop-electron/src/main/index.ts ++++ b/packages/desktop-electron/src/main/index.ts +@@ -1,7 +1,6 @@ + import { randomUUID } from "node:crypto" + import { EventEmitter } from "node:events" + import { existsSync } from "node:fs" +-import { createServer } from "node:net" + import { homedir } from "node:os" + import { join } from "node:path" + import type { Event } from "electron" +@@ -32,33 +31,54 @@ app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev")) + const { autoUpdater } = pkg + +-import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" ++import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" + import { checkAppExists, resolveAppPath, wslPath } from "./apps" +-import { CHANNEL, UPDATER_ENABLED } from "./constants" ++import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" + import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" + import { initLogging } from "./logging" + import { parseMarkdown } from "./markdown" + import { createMenu } from "./menu" +-import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" ++import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" ++import { store } from "./store" ++import { createWslServersController } from "./wsl-servers" + import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +-import type { Server } from "virtual:opencode-server" + + const initEmitter = new EventEmitter() + let initStep: InitStep = { phase: "server_waiting" } + + let mainWindow: BrowserWindow | null = null +-let server: Server.Listener | null = null ++let server: { stop(): void } | null = null + const loadingComplete = defer() + + const pendingDeepLinks: string[] = [] + + const serverReady = defer() ++void serverReady.promise.catch(() => undefined) + const logger = initLogging() ++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), ++ }, ++) + + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + }) ++logger.log("config paths", { ++ userData: app.getPath("userData"), ++ settingsStore: store.path, ++ wslServersKey: WSL_SERVERS_KEY, ++ wslServers: store.get(WSL_SERVERS_KEY) ?? null, ++}) + + setupApp() + +@@ -66,6 +86,14 @@ function setupApp() { + ensureLoopbackNoProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + ++ process.on("uncaughtException", (error) => { ++ logger.error("main process uncaught exception", error) ++ }) ++ ++ process.on("unhandledRejection", (reason) => { ++ logger.error("main process unhandled rejection", reason) ++ }) ++ + if (!app.requestSingleInstanceLock()) { + app.quit() + return +@@ -88,15 +116,18 @@ function setupApp() { + + app.on("before-quit", () => { + killSidecar() ++ wslServers.stopAll() + }) + + app.on("will-quit", () => { + killSidecar() ++ wslServers.stopAll() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + killSidecar() ++ wslServers.stopAll() + app.exit(0) + }) + } +@@ -132,19 +163,38 @@ async function initialize() { + const sqliteDone = needsMigration ? defer() : undefined + let overlay: BrowserWindow | null = null + +- const port = await getSidecarPort() ++ const port = await allocatePort() + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() ++ const key = "local:windows" + +- logger.log("spawning sidecar", { url }) +- const { listener, health } = await spawnLocalServer(hostname, port, password) +- server = listener +- serverReady.resolve({ ++ logger.log("spawning windows sidecar", { url }) ++ const startupData: ServerReadyData = { + url, + username: "opencode", + password, +- }) ++ local: { ++ key, ++ url, ++ username: "opencode", ++ password, ++ }, ++ } ++ let startupError: Error | null = null ++ const startup = await (async () => { ++ try { ++ return await spawnLocalServer(hostname, port, password) ++ } catch (error) { ++ startupError = asError(error) ++ logger.error("windows sidecar startup failed", startupError) ++ return undefined ++ } ++ })() ++ server = startup?.listener ?? null ++ ++ // Initialize WSL sidecars in parallel; failures do not block app startup. ++ void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) + + const loadingTask = (async () => { + logger.log("sidecar connection started", { url }) +@@ -160,14 +210,24 @@ async function initialize() { + await sqliteDone?.promise + } + +- await Promise.race([ +- health.wait, +- delay(30_000).then(() => { +- throw new Error("Sidecar health check timed out") +- }), +- ]).catch((error) => { +- logger.error("sidecar health check failed", error) +- }) ++ if (startup) { ++ await Promise.race([ ++ startup.health.wait, ++ delay(30_000).then(() => { ++ throw new Error("Sidecar health check timed out") ++ }), ++ ]) ++ .then(() => { ++ serverReady.resolve(startupData) ++ }) ++ .catch((error) => { ++ startupError = asError(error) ++ logger.error("sidecar health check failed", startupError) ++ serverReady.reject(startupError) ++ }) ++ } else { ++ serverReady.reject(startupError ?? new Error("Local server startup failed")) ++ } + + logger.log("loading task finished") + })() +@@ -181,6 +241,7 @@ async function initialize() { + const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) + if (show) { + overlay = createLoadingWindow(globals) ++ wireWindowDiagnostics(overlay, "loading") + await delay(1_000) + } + } +@@ -193,11 +254,67 @@ async function initialize() { + } + + mainWindow = createMainWindow(globals) ++ wireWindowDiagnostics(mainWindow, "main") + wireMenu() + + overlay?.close() + } + ++function wireWindowDiagnostics(win: BrowserWindow, label: string) { ++ win.webContents.on("console-message", (_event, level, message, line, sourceId) => { ++ // Render `message` as a block so multi-line stack traces survive; the ++ // previous shape stuffed the message into a JSON object which escaped ++ // `\n` and made stacks unreadable. ++ const location = sourceId ? ` [${sourceId}:${line}]` : "" ++ const text = `${label} renderer${location}\n${message}` ++ if (level >= 3) { ++ logger.error(text) ++ return ++ } ++ if (level >= 2) { ++ logger.warn(text) ++ return ++ } ++ logger.log(text) ++ }) ++ ++ win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { ++ logger.error(`${label} renderer failed load`, { ++ errorCode, ++ errorDescription, ++ validatedURL, ++ isMainFrame, ++ }) ++ }) ++ ++ win.webContents.on("render-process-gone", (_event, details) => { ++ logger.error(`${label} renderer process gone`, details) ++ }) ++ ++ win.webContents.on("preload-error", (_event, path, error) => { ++ logger.error(`${label} preload error`, { ++ path, ++ error: error instanceof Error ? (error.stack ?? error.message) : String(error), ++ }) ++ }) ++ ++ // DevTools accelerators on Windows/Linux where the menu isn't created. ++ win.webContents.on("before-input-event", (_event, input) => { ++ if (input.type !== "keyDown") return ++ const key = input.key ++ const toggle = ++ key === "F12" || ++ (input.control && input.shift && (key === "I" || key === "i")) || ++ (input.meta && input.alt && (key === "I" || key === "i")) ++ if (!toggle) return ++ win.webContents.toggleDevTools() ++ }) ++ ++ win.on("unresponsive", () => { ++ logger.error(`${label} window became unresponsive`) ++ }) ++} ++ + function wireMenu() { + if (!mainWindow) return + createMenu({ +@@ -206,16 +323,13 @@ function wireMenu() { + void checkForUpdates(true) + }, + reload: () => mainWindow?.reload(), +- relaunch: () => { +- killSidecar() +- app.relaunch() +- app.exit(0) +- }, ++ relaunch: () => relaunchApp(), + }) + } + + registerIpcHandlers({ + killSidecar: () => killSidecar(), ++ relaunch: () => relaunchApp(), + awaitInitialization: async (sendStep) => { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) +@@ -229,15 +343,29 @@ registerIpcHandlers({ + initEmitter.off("step", listener) + } + }, ++ getWslServersState: () => wslServers.getState(), ++ onWslServersEvent: (listener) => wslServers.subscribe(listener), ++ wslServersProbeRuntime: () => wslServers.probeRuntime(), ++ wslServersRefreshDistros: () => wslServers.refreshDistros(), ++ wslServersInstallWsl: () => wslServers.installWsl(), ++ wslServersInstallDistro: (name) => wslServers.installDistro(name), ++ wslServersProbeDistro: (name) => wslServers.probeDistro(name), ++ wslServersProbeOpencode: (name) => wslServers.probeOpencode(name), ++ wslServersInstallOpencode: (name) => wslServers.installOpencode(name), ++ wslServersOpenTerminal: (name) => wslServers.openTerminal(name), ++ wslServersAddServer: (distro) => wslServers.addServer(distro), ++ wslServersRemoveServer: (id) => wslServers.removeServer(id), ++ wslServersStartServer: (id) => wslServers.startServer(id), ++ wslServersStopServer: (id) => wslServers.stopServer(id), ++ wslServersCancelJob: () => wslServers.cancelJob(), ++ wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks), + 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: async (appName) => checkAppExists(appName), +- wslPath: async (path, mode) => wslPath(path, mode), ++ wslPath: async (path, mode, distro) => wslPath(path, mode, distro), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => loadingComplete.resolve(), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), +@@ -252,6 +380,15 @@ function killSidecar() { + server = null + } + ++function relaunchApp() { ++ // app.exit() skips before-quit / will-quit, so relaunch callers must ++ // explicitly stop sidecars here rather than relying on process hooks. ++ killSidecar() ++ wslServers.stopAll() ++ app.relaunch() ++ app.exit(0) ++} ++ + function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { +@@ -272,29 +409,6 @@ function ensureLoopbackNoProxy() { + upsert("no_proxy") + } + +-async function getSidecarPort() { +- const fromEnv = process.env.OPENCODE_PORT +- if (fromEnv) { +- const parsed = Number.parseInt(fromEnv, 10) +- if (!Number.isNaN(parsed)) return parsed +- } +- +- return await new Promise((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 +- } +- const port = address.port +- server.close(() => resolve(port)) +- }) +- }) +-} +- + function sqliteFileExists() { + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") +@@ -358,6 +472,7 @@ async function checkUpdate() { + async function installUpdate() { + if (!updateReady) return + killSidecar() ++ wslServers.stopAll() + autoUpdater.quitAndInstall() + } + +@@ -408,6 +523,10 @@ function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + ++function asError(error: unknown) { ++ return error instanceof Error ? error : new Error(String(error)) ++} ++ + function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void +diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts +index 52d87ed7ee..c6d2c4face 100644 +--- a/packages/desktop-electron/src/main/ipc.ts ++++ b/packages/desktop-electron/src/main/ipc.ts +@@ -2,7 +2,16 @@ import { execFile } from "node:child_process" + import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" + import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" + +-import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" ++import type { ++ InitStep, ++ ServerReadyData, ++ SqliteMigrationProgress, ++ TitlebarTheme, ++ WslServerAcknowledgements, ++ WslServerConfig, ++ WslServersEvent, ++ WslServersState, ++} from "../preload/types" + import { getStore } from "./store" + import { setTitlebar } from "./windows" + +@@ -13,16 +22,31 @@ const pickerFilters = (ext?: string[]) => { + + type Deps = { + killSidecar: () => void ++ relaunch: () => void + awaitInitialization: (sendStep: (step: InitStep) => void) => Promise ++ getWslServersState: () => Promise | WslServersState ++ onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void ++ wslServersProbeRuntime: () => Promise | void ++ wslServersRefreshDistros: () => Promise | void ++ wslServersInstallWsl: () => Promise | void ++ wslServersInstallDistro: (name: string) => Promise | void ++ wslServersProbeDistro: (name: string) => Promise | void ++ wslServersProbeOpencode: (name: string) => Promise | void ++ wslServersInstallOpencode: (name: string) => Promise | void ++ wslServersOpenTerminal: (name: string) => Promise | void ++ wslServersAddServer: (distro: string) => Promise | WslServerConfig ++ wslServersRemoveServer: (id: string) => Promise | void ++ wslServersStartServer: (id: string) => Promise | void ++ wslServersStopServer: (id: string) => Promise | void ++ wslServersCancelJob: () => Promise | void ++ wslServersUpdateAcknowledgements: (id: string, acks: Partial) => Promise | void + getDefaultServerUrl: () => Promise | string | null + setDefaultServerUrl: (url: string | null) => Promise | void +- getWslConfig: () => Promise +- setWslConfig: (config: WslConfig) => Promise | void + getDisplayBackend: () => Promise + setDisplayBackend: (backend: string | null) => Promise | void + parseMarkdown: (markdown: string) => Promise | string + checkAppExists: (appName: string) => Promise | boolean +- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise ++ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise + resolveAppPath: (appName: string) => Promise + loadingWindowComplete: () => void + runUpdater: (alertOnFail: boolean) => Promise | void +@@ -32,25 +56,62 @@ type Deps = { + } + + export function registerIpcHandlers(deps: Deps) { ++ const offWslServers = deps.onWslServersEvent((payload) => { ++ for (const win of BrowserWindow.getAllWindows()) { ++ if (win.isDestroyed()) continue ++ win.webContents.send("wsl-servers-event", payload) ++ } ++ }) ++ app.once("will-quit", offWslServers) ++ + ipcMain.handle("kill-sidecar", () => deps.killSidecar()) + ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { + const send = (step: InitStep) => event.sender.send("init-step", step) + return deps.awaitInitialization(send) + }) ++ ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) ++ ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) ++ ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) ++ ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) ++ ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => ++ deps.wslServersInstallDistro(name), ++ ) ++ ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => ++ deps.wslServersProbeDistro(name), ++ ) ++ ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => ++ deps.wslServersProbeOpencode(name), ++ ) ++ ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => ++ deps.wslServersInstallOpencode(name), ++ ) ++ ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => ++ deps.wslServersOpenTerminal(name), ++ ) ++ ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro)) ++ ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id)) ++ ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id)) ++ ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id)) ++ ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) ++ ipcMain.handle( ++ "wsl-servers-update-acknowledgements", ++ (_event: IpcMainInvokeEvent, id: string, acks: Partial) => ++ deps.wslServersUpdateAcknowledgements(id, acks), ++ ) + ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) + 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( ++ "wsl-path", ++ (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) => ++ deps.wslPath(path, mode, distro), + ) + ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) +@@ -167,8 +228,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()) +diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts +index fcf209fb67..f55554a8eb 100644 +--- a/packages/desktop-electron/src/main/menu.ts ++++ b/packages/desktop-electron/src/main/menu.ts +@@ -75,9 +75,9 @@ export function createMenu(deps: Deps) { + { role: "reload" }, + { role: "toggleDevTools" }, + { type: "separator" }, +- { role: "resetZoom" }, +- { role: "zoomIn" }, +- { role: "zoomOut" }, ++ { label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") }, ++ { label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") }, ++ { label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], +diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts +index 5a6050013a..ffb1d2e262 100644 +--- a/packages/desktop-electron/src/main/server.ts ++++ b/packages/desktop-electron/src/main/server.ts +@@ -1,9 +1,11 @@ ++import { spawn } from "node:child_process" ++import { randomUUID } from "node:crypto" ++import { createServer } from "node:net" + import { app } 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 { store } from "./store" +- +-export type WslConfig = { enabled: boolean } ++import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" + + export type HealthCheck = { wait: Promise } + +@@ -21,13 +23,26 @@ export function setDefaultServerUrl(url: string | null) { + store.delete(DEFAULT_SERVER_URL_KEY) + } + +-export function getWslConfig(): WslConfig { +- const value = store.get(WSL_ENABLED_KEY) +- return { enabled: typeof value === "boolean" ? value : false } +-} +- +-export function setWslConfig(config: WslConfig) { +- store.set(WSL_ENABLED_KEY, config.enabled) ++export async function allocatePort() { ++ const fromEnv = process.env.OPENCODE_PORT ++ if (fromEnv) { ++ const parsed = Number.parseInt(fromEnv, 10) ++ if (!Number.isNaN(parsed)) return parsed ++ } ++ return new Promise((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 ++ } ++ const port = address.port ++ server.close(() => resolve(port)) ++ }) ++ }) + } + + export async function spawnLocalServer(hostname: string, port: number, password: string) { +@@ -57,6 +72,107 @@ export async function spawnLocalServer(hostname: string, port: number, password: + return { listener, health: { wait } } + } + ++export type WslSidecar = { ++ listener: { stop: () => void } ++ url: string ++ username: string | null ++ password: string ++} ++ ++export async function spawnWslSidecar( ++ distro: string, ++ opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ++): Promise { ++ // Every wsl.exe invocation below goes through wslArgs which injects ++ // `--user root`. That matters even when a distro has DefaultUid=0 ++ // (i.e. the interactive first-run user account setup never ran): ++ // explicit --user root bypasses the OOBE hook that would otherwise ++ // prompt on stdin, so we can resolve opencode and spawn the sidecar ++ // without any machine-wide first-run handshake. The earlier Ubuntu ++ // hang was caused by invoking without --user (default uid 0 triggers ++ // OOBE), not by the registry state itself. We still have a 20s ++ // timeout in runCommand as a safety net for true wsl.exe wedges. ++ 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", ++ "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", ++ "export OPENCODE_EXPERIMENTAL_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}`, ++ ].join("\n") ++ ++ const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { ++ stdio: ["pipe", "pipe", "pipe"], ++ windowsHide: true, ++ }) ++ child.stdin.end(script) ++ ++ let settled = false ++ const recentOutput: string[] = [] ++ const emit = (line: WslCommandLine) => { ++ if (settled || !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((_, 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 healthPromise = (async () => { ++ while (true) { ++ await new Promise((resolve) => setTimeout(resolve, 100)) ++ if (await checkHealth(url, password)) return ++ } ++ })() ++ ++ const timeoutMs = opts.healthTimeoutMs ?? 30_000 ++ const timeout = new Promise((_, reject) => { ++ const id = setTimeout( ++ () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)), ++ timeoutMs, ++ ) ++ void healthPromise.finally(() => clearTimeout(id)) ++ }) ++ ++ try { ++ await Promise.race([healthPromise, exit, timeout]) ++ } catch (error) { ++ child.kill() ++ throw error ++ } finally { ++ settled = true ++ } ++ ++ return { ++ listener: { ++ stop() { ++ child.kill() ++ }, ++ }, ++ url, ++ username, ++ password, ++ } ++} ++ + function prepareServerEnv(password: string) { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} +@@ -73,6 +189,33 @@ function prepareServerEnv(password: string) { + Object.assign(process.env, env) + } + ++function shellEscape(value: string) { ++ return `'${value.replace(/'/g, `'"'"'`)}'` ++} ++ ++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() ?? "" ++ for (const line of lines) onLine({ stream: source, text: line }) ++ }) ++ 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}` ++} ++ + export async function checkHealth(url: string, password?: string | null): Promise { + let healthUrl: URL + try { +diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts +index 95f80c1240..26f138f5fb 100644 +--- a/packages/desktop-electron/src/main/windows.ts ++++ b/packages/desktop-electron/src/main/windows.ts +@@ -134,7 +134,9 @@ export function createLoadingWindow(globals: Globals) { + function loadWindow(win: BrowserWindow, html: string) { + const devUrl = process.env.ELECTRON_RENDERER_URL + if (devUrl) { +- const url = new URL(html, devUrl) ++ const base = new URL(devUrl) ++ if (base.hostname === "localhost") base.hostname = "127.0.0.1" ++ const url = new URL(html, base) + void win.loadURL(url.toString()) + return + } +@@ -157,7 +159,9 @@ function injectGlobals(win: BrowserWindow, globals: Globals) { + + function wireZoom(win: BrowserWindow) { + win.webContents.setZoomFactor(1) +- win.webContents.on("zoom-changed", () => { +- win.webContents.setZoomFactor(1) +- }) ++ // Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled ++ // in the renderer so the Solid `webviewZoom` signal stays the single source ++ // of truth; a stray `zoom-changed` handler here would race with the renderer ++ // and intermittently snap the factor back to 1. ++ void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined) + } +diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts +new file mode 100644 +index 0000000000..c35e4e52bf +--- /dev/null ++++ b/packages/desktop-electron/src/main/wsl-servers.ts +@@ -0,0 +1,522 @@ ++import type { ++ WslDistroProbe, ++ WslInstalledDistro, ++ WslJob, ++ WslOnlineDistro, ++ WslOpencodeCheck, ++ WslRuntimeCheck, ++ WslServerAcknowledgements, ++ WslServerConfig, ++ WslServerItem, ++ WslServerRuntime, ++ WslServersEvent, ++ WslServersState, ++ WslTranscriptLine, ++} from "../preload/types" ++import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" ++import { spawnWslSidecar } from "./server" ++import { store } from "./store" ++import type { WslCommandLine } from "./wsl" ++import { ++ installWslDistro, ++ installWslOpencode, ++ installWslRuntimeElevated, ++ listInstalledWslDistros, ++ listOnlineWslDistros, ++ openWslTerminal, ++ probeWslDistro, ++ probeWslRuntime, ++ readWslCommandVersion, ++ resolveWslOpencode, ++ upgradeWslOpencode, ++ wslNeedsRestart, ++} from "./wsl" ++ ++type RunningSidecar = { ++ listener: { stop: () => void } ++ url: string ++ username: string | null ++ password: string ++} ++ ++type SpawnSidecar = (distro: string) => Promise ++ ++type ControllerLogger = { ++ log: (message: string, meta?: unknown) => void ++ error: (message: string, meta?: unknown) => void ++} ++ ++export type WslServersController = ReturnType ++ ++export function wslServerIdForDistro(distro: string) { ++ return `wsl:${distro}` ++} ++ ++export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { ++ const mainLogger: ControllerLogger | undefined = logger ++ let state: WslServersState = initialState() ++ const listeners = new Set<(event: WslServersEvent) => void>() ++ const sidecars = new Map() ++ const startAttempts = new Map() ++ let jobAbort: AbortController | undefined ++ ++ const emit = () => { ++ for (const listener of listeners) listener({ type: "state", state }) ++ } ++ ++ const setState = (next: Partial) => { ++ state = { ...state, ...next } ++ emit() ++ } ++ ++ const appendTranscript = (line: Omit) => { ++ setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] }) ++ } ++ ++ const clearTranscript = () => setState({ transcript: [] }) ++ ++ const persistServers = (servers: WslServerConfig[]) => { ++ store.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, opts: { keepTranscript?: boolean } = {}): AbortController => { ++ jobAbort?.abort() ++ const abort = new AbortController() ++ jobAbort = abort ++ if (!opts.keepTranscript) clearTranscript() ++ setState({ job, lastError: null }) ++ return abort ++ } ++ ++ const endJob = (abort: AbortController, error?: Error | null) => { ++ if (jobAbort !== abort) return ++ jobAbort = undefined ++ setState({ job: null, lastError: error?.message ?? null }) ++ } ++ ++ const onLine = (line: WslCommandLine) => appendTranscript(line) ++ ++ 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 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" }) ++ mainLogger?.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, ++ }) ++ 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 ++ 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. ++ mainLogger?.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 ++ try { ++ existing.listener.stop() ++ } catch { ++ // ignore stop errors ++ } ++ sidecars.delete(id) ++ } ++ ++ const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { ++ 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, err) ++ throw err ++ } ++ } ++ ++ return { ++ getState() { ++ return state ++ }, ++ subscribe(listener: (event: WslServersEvent) => void) { ++ listeners.add(listener) ++ return () => listeners.delete(listener) ++ }, ++ ++ async initialize() { ++ refreshFromStore() ++ await Promise.all(state.servers.map((item) => startServer(item.config.id))) ++ }, ++ ++ async probeRuntime() { ++ await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: "Checking WSL runtime" }) ++ const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) ++ setState({ ++ runtime, ++ pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, ++ }) ++ }) ++ }, ++ ++ async refreshDistros() { ++ await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: "Listing WSL distros" }) ++ const [installedResult, onlineResult] = await Promise.allSettled([ ++ listInstalledWslDistros({ signal: abort.signal, onLine }), ++ listOnlineWslDistros({ signal: abort.signal, onLine }), ++ ]) ++ const installed = installedResult.status === "fulfilled" ? installedResult.value : [] ++ const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] ++ setState({ installed, online }) ++ }) ++ }, ++ ++ async installWsl() { ++ await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: "Installing WSL runtime" }) ++ const result = await installWslRuntimeElevated({ signal: abort.signal, onLine }) ++ if (result.code !== 0) { ++ const message = summarize(result.stderr || result.stdout) || "WSL installation failed" ++ throw new Error(message) ++ } ++ const pendingRestart = wslNeedsRestart(result) ++ setState({ pendingRestart }) ++ if (!pendingRestart) { ++ const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) ++ setState({ runtime }) ++ } ++ }) ++ }, ++ ++ async installDistro(name: string) { ++ await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) ++ const result = await installWslDistro(name, { signal: abort.signal, onLine }) ++ if (result.code !== 0) { ++ const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` ++ throw new Error(message) ++ } ++ const [installedResult, onlineResult] = await Promise.allSettled([ ++ listInstalledWslDistros({ signal: abort.signal, onLine }), ++ listOnlineWslDistros({ signal: abort.signal, onLine }), ++ ]) ++ const installed = installedResult.status === "fulfilled" ? installedResult.value : [] ++ const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] ++ const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) ++ setState({ ++ installed, ++ online, ++ distroProbes: { ...state.distroProbes, [name]: probe }, ++ }) ++ }) ++ }, ++ ++ async probeDistro(name: string) { ++ await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: `Checking ${name}` }) ++ const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) ++ setState({ distroProbes: { ...state.distroProbes, [name]: probe } }) ++ }) ++ }, ++ ++ 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), ++ }, ++ }) ++ }) ++ }, ++ ++ async installOpencode(name: string) { ++ await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { ++ appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` }) ++ const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine }) ++ const existingVersion = resolved ++ ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) ++ : null ++ const result = ++ resolved && existingVersion ++ ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine }) ++ : await installWslOpencode(appVersion, name, { signal: abort.signal, onLine }) ++ 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), ++ }, ++ }) ++ }) ++ }, ++ ++ async openTerminal(name: string) { ++ await openWslTerminal(name) ++ }, ++ ++ async cancelJob() { ++ jobAbort?.abort() ++ jobAbort = undefined ++ appendTranscript({ stream: "system", text: "Canceled" }) ++ setState({ job: null }) ++ }, ++ ++ async addServer(distro: string): Promise { ++ 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, ++ acknowledgements: { root: false, mismatch: null }, ++ } ++ persistServers([...readPersistedServers(), config]) ++ setState({ ++ servers: [...state.servers, { config, runtime: { kind: "starting" } }], ++ }) ++ void startServer(id) ++ return config ++ }, ++ ++ async removeServer(id: string) { ++ 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) }) ++ }, ++ ++ startServer, ++ ++ async stopServer(id: string) { ++ invalidateStartAttempt(id) ++ await stopServerInternal(id) ++ setRuntime(id, { kind: "stopped" }) ++ }, ++ ++ async updateAcknowledgements(id: string, acks: Partial) { ++ const persisted = readPersistedServers() ++ const next = persisted.map((config) => ++ config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config, ++ ) ++ persistServers(next) ++ refreshFromStore() ++ }, ++ ++ stopAll() { ++ for (const item of state.servers) invalidateStartAttempt(item.config.id) ++ for (const [id] of sidecars) { ++ const existing = sidecars.get(id) ++ try { ++ existing?.listener.stop() ++ } catch { ++ // ignore ++ } ++ } ++ sidecars.clear() ++ }, ++ } ++} ++ ++function initialState(): WslServersState { ++ return { ++ runtime: null, ++ installed: [], ++ online: [], ++ distroProbes: {}, ++ opencodeChecks: {}, ++ pendingRestart: false, ++ servers: [], ++ job: null, ++ transcript: [], ++ lastError: null, ++ } ++} ++ ++function readPersistedServers(): WslServerConfig[] { ++ 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) ++ } ++ const migrated = migrateLegacyLocalServer() ++ if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) ++ return migrated ++} ++ ++function migrateLegacyLocalServer(): WslServerConfig[] { ++ const legacy = store.get(LEGACY_LOCAL_SERVER_KEY) ++ if (!legacy || typeof legacy !== "object") return [] ++ const record = legacy as Record ++ if (record.mode !== "wsl") return [] ++ const distro = typeof record.distro === "string" ? record.distro : null ++ if (!distro) return [] ++ return [ ++ { ++ id: wslServerIdForDistro(distro), ++ distro, ++ acknowledgements: { root: false, mismatch: null }, ++ }, ++ ] ++} ++ ++function normalizePersistedServer(value: unknown): WslServerConfig[] { ++ if (!value || typeof value !== "object") return [] ++ const record = value as Record ++ 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, ++ acknowledgements: normalizeAcks(record.acknowledgements), ++ }, ++ ] ++} ++ ++function normalizeAcks(value: unknown): WslServerAcknowledgements { ++ const record = value && typeof value === "object" ? (value as Record) : {} ++ const mismatch = ++ record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record) : null ++ return { ++ root: record.root === true, ++ mismatch: ++ mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string" ++ ? { path: mismatch.path, version: mismatch.version } ++ : null, ++ } ++} ++ ++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 summarize(value: string) { ++ return value ++ .split(/\r?\n/g) ++ .map((line) => line.trim()) ++ .filter(Boolean) ++ .join("\n") ++} ++ ++// Re-export types used by callers ++export type { ++ WslInstalledDistro, ++ WslOnlineDistro, ++ WslRuntimeCheck, ++ WslDistroProbe, ++ WslOpencodeCheck, ++ WslServerConfig, ++ WslServerItem, ++ WslServerRuntime, ++ WslServersEvent, ++ WslServersState, ++} +diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts +new file mode 100644 +index 0000000000..07a22b8252 +--- /dev/null ++++ b/packages/desktop-electron/src/main/wsl.ts +@@ -0,0 +1,491 @@ ++import { spawn } from "node:child_process" ++import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" ++ ++export type WslCommandLine = { ++ stream: "stdout" | "stderr" ++ text: string ++} ++ ++export type WslCommandResult = { ++ code: number | null ++ signal: NodeJS.Signals | null ++ stdout: string ++ stderr: string ++} ++ ++type RunWslOptions = { ++ onLine?: (line: WslCommandLine) => void ++ 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 ++ ++// `--user root` bypasses the distro's default-user requirement. A freshly ++// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively ++// for a username/password on its first invocation; when spawned with ++// piped stdio that prompt blocks forever or silently reads garbage, ++// leaving the sidecar hanging and the server unhealthy. Running as root ++// sidesteps the entire first-run setup flow — opencode only needs an ++// HTTP listener in the distro, not a per-user environment, so root is ++// a safe default for the sidecar process. ++export function wslArgs(args: string[], distro?: string | null) { ++ if (distro) return ["-d", distro, "--user", "root", "--", ...args] ++ return ["--user", "root", "--", ...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((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 = "" ++ let stdoutPending = "" ++ let stderrPending = "" ++ const stdoutDecoder = createOutputDecoder() ++ const stderrDecoder = createOutputDecoder() ++ ++ const flush = (stream: WslCommandLine["stream"], pending: string) => { ++ if (!pending) return "" ++ opts.onLine?.({ stream, text: pending }) ++ return "" ++ } ++ ++ 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 }) ++ 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 }) ++ } ++ ++ child.stdout.on("data", (chunk: Buffer) => { ++ append("stdout", stdoutDecoder.decode(chunk)) ++ }) ++ child.stdout.on("end", () => { ++ append("stdout", stdoutDecoder.flush()) ++ stdoutPending = flush("stdout", stdoutPending) ++ }) ++ ++ child.stderr.on("data", (chunk: Buffer) => { ++ append("stderr", stderrDecoder.decode(chunk)) ++ }) ++ child.stderr.on("end", () => { ++ append("stderr", stderrDecoder.flush()) ++ stderrPending = flush("stderr", stderrPending) ++ }) ++ ++ child.once("error", (error) => { ++ clearTimeout(timeoutId) ++ reject(error) ++ }) ++ child.once("close", (code, signal) => { ++ clearTimeout(timeoutId) ++ resolve({ code, signal, 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 type WslRegistryDistro = { ++ name: string ++ defaultUid: number ++ state: number ++ version: number ++} ++ ++// Distros that are designed to run as root and don't have a user-level ++// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that ++// prompts for a UNIX username on first invocation; if that never runs, ++// wsl.exe -d hangs silently forever. ++const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) ++ ++// Read LXSS metadata from the Windows registry. This never invokes ++// wsl.exe, so it is safe to call when wsl.exe itself is wedged. ++// DefaultUid === 0 on a user-oriented distro means the first-run ++// "Create a default UNIX user account" step never completed. ++// ++// Uses a `reg query` fallback strategy because some hosts (e.g. Electron ++// spawning PowerShell with certain user profiles) return nothing from the ++// PowerShell registry provider; parsing `reg query` output is ugly but ++// native Windows and always available. ++export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise { ++ // `reg query` prints each subkey's values in a stable format: ++ // ++ // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{guid} ++ // DistributionName REG_SZ Ubuntu-24.04 ++ // DefaultUid REG_DWORD 0x0 ++ // State REG_DWORD 0x1 ++ // Version REG_DWORD 0x2 ++ // ... ++ const result = await runCommand( ++ "reg.exe", ++ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss", "/s"], ++ opts, ++ ) ++ const stdout = result.stdout ++ if (result.code !== 0 || !stdout) { ++ ;(opts?.onLine ?? (() => undefined))({ ++ stream: "stderr", ++ text: `reg query failed code=${result.code} stderr=${result.stderr.slice(0, 200)}`, ++ }) ++ return [] ++ } ++ const blocks = stdout.split(/\r?\n\r?\n/) ++ const out: WslRegistryDistro[] = [] ++ for (const block of blocks) { ++ const header = block.match(/^(HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\\{[^}]+\})/i) ++ if (!header) continue ++ const name = block.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/m)?.[1] ++ if (!name) continue ++ const uidHex = block.match(/^\s+DefaultUid\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" ++ const stateHex = block.match(/^\s+State\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" ++ const versionHex = block.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" ++ out.push({ ++ name, ++ defaultUid: Number.parseInt(uidHex, 16), ++ state: Number.parseInt(stateHex, 16), ++ version: Number.parseInt(versionHex, 16), ++ }) ++ } ++ return out ++} ++ ++export type WslFirstRunCheck = ++ | { status: "ok" } ++ | { status: "needs-first-run"; defaultUid: number } ++ | { status: "not-installed" } ++ ++export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise { ++ const distros = await readWslDistrosFromRegistry(opts) ++ const entry = distros.find((d) => d.name === distro) ++ if (!entry) return { status: "not-installed" } ++ if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" } ++ if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid } ++ return { status: "ok" } ++} ++ ++export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { ++ return runWslInDistro(["sh", "-lc", script], distro, opts) ++} ++ ++export function runWslBash(script: string, distro?: string | null, opts?: RunWslOptions) { ++ return runWslInDistro(["bash", "-lc", script], distro, opts) ++} ++ ++export async function probeWslRuntime(opts?: RunWslOptions): Promise { ++ 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, ++ status: null, ++ error: summarize(version.stderr || version.stdout) || "WSL is unavailable", ++ } ++ } ++ ++ const status = await runWsl(["--status"], opts).catch(() => undefined) ++ return { ++ available: true, ++ version: firstLine(version.stdout), ++ status: status?.code === 0 ? summarize(status.stdout) : null, ++ 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 installWslRuntime(opts?: RunWslOptions) { ++ return runWsl(["--install", "--no-distribution"], opts) ++} ++ ++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, opts) ++} ++ ++export async function installWslDistro(name: string, opts?: RunWslOptions) { ++ return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) ++} ++ ++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, ++ ) ++} ++ ++export function wslNeedsRestart(result: WslCommandResult) { ++ return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) ++} ++ ++export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { ++ 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, ++ username: null, ++ isRoot: null, ++ error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", ++ } ++ } ++ ++ const [bash, curl, user] = 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), ++ runWslSh("id -un 2>/dev/null || true", name, opts), ++ ]) ++ ++ const username = summarize(user.stdout) ++ return { ++ name, ++ canExecute: true, ++ hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", ++ hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", ++ username: username || null, ++ isRoot: username ? username === "root" : null, ++ error: null, ++ } ++} ++ ++async function readWslDefaultUser(distro: string, opts?: RunWslOptions) { ++ const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro) ++ if (!entry || entry.defaultUid === 0) return null ++ ++ const passwd = firstLine( ++ ( ++ await runWslSh( ++ [ ++ "if command -v getent >/dev/null 2>&1; then", ++ ` getent passwd ${entry.defaultUid}`, ++ "else", ++ ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`, ++ "fi", ++ ].join("\n"), ++ distro, ++ opts, ++ ) ++ ).stdout, ++ ) ++ if (!passwd) return null ++ ++ const parts = passwd.split(":") ++ const username = parts[0]?.trim() ?? "" ++ const home = parts[5]?.trim() ?? "" ++ if (!home) return null ++ return { username: username || null, home } ++} ++ ++export async function resolveWslHome(distro: string, opts?: RunWslOptions) { ++ return (await readWslDefaultUser(distro, opts))?.home ?? "/root" ++} ++ ++function opencodeCandidate(path: string) { ++ return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi` ++} ++ ++export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { ++ const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) ++ if (command && !command.startsWith("/mnt/")) return command ++ ++ const home = await resolveWslHome(distro, opts) ++ for (const candidate of [ ++ ...(home !== "/root" ++ ? [ ++ opencodeCandidate(`${home}/.local/bin/opencode`), ++ opencodeCandidate(`${home}/bin/opencode`), ++ opencodeCandidate(`${home}/.opencode/bin/opencode`), ++ ] ++ : []), ++ 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', ++ 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', ++ 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', ++ 'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi', ++ ]) { ++ const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout) ++ if (resolved) return resolved ++ } ++ ++ return null ++} ++ ++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 async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) { ++ return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts) ++} ++ ++export function openWslTerminal(distro?: string | null) { ++ return new Promise((resolve, reject) => { ++ const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", 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, state, version] = match ++ if (!name || /^name$/i.test(name)) return [] ++ return [ ++ { ++ name: name.trim(), ++ state: state || null, ++ 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 ++ ) ++} ++ ++function summarize(value: string) { ++ return value ++ .split(/\r?\n/g) ++ .map((line) => line.trim()) ++ .filter(Boolean) ++ .join("\n") ++} ++ ++function shellEscape(value: string) { ++ return `'${value.replace(/'/g, `'"'"'`)}'` ++} +diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts +index 296fcb2f1c..faf0d692cb 100644 +--- a/packages/desktop-electron/src/preload/index.ts ++++ b/packages/desktop-electron/src/preload/index.ts +@@ -1,5 +1,5 @@ + import { contextBridge, ipcRenderer } from "electron" +-import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" ++import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" + + const api: ElectronAPI = { + killSidecar: () => ipcRenderer.invoke("kill-sidecar"), +@@ -11,15 +11,35 @@ const api: ElectronAPI = { + ipcRenderer.removeListener("init-step", handler) + }) + }, ++ wslServers: { ++ getState: () => ipcRenderer.invoke("wsl-servers-get-state"), ++ subscribe: (cb) => { ++ const handler = (_: unknown, event: WslServersEvent) => cb(event) ++ ipcRenderer.on("wsl-servers-event", handler) ++ return () => ipcRenderer.removeListener("wsl-servers-event", handler) ++ }, ++ 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), ++ stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id), ++ cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"), ++ updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks), ++ }, + 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), ++ wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro), + 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), +diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts +index f8e6d52c7d..18183868ad 100644 +--- a/packages/desktop-electron/src/preload/types.ts ++++ b/packages/desktop-electron/src/preload/types.ts +@@ -4,11 +4,120 @@ export type ServerReadyData = { + url: string + username: string | null + password: string | null ++ local: { ++ key: string ++ url: string ++ username: string | null ++ password: string | null ++ } + } + + export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } + +-export type WslConfig = { enabled: boolean } ++export type WslServerStep = "wsl" | "distro" | "opencode" ++ ++export type WslRuntimeCheck = { ++ available: boolean ++ version: string | null ++ status: string | null ++ error: string | null ++} ++export type WslInstalledDistro = { ++ name: string ++ state: string | null ++ version: number | null ++ isDefault: boolean ++} ++export type WslOnlineDistro = { ++ name: string ++ label: string ++} ++export type WslDistroProbe = { ++ name: string ++ canExecute: boolean ++ hasBash: boolean ++ hasCurl: boolean ++ username: string | null ++ isRoot: boolean | null ++ 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 WslTranscriptLine = { ++ stream: "stdout" | "stderr" | "system" ++ text: string ++ at: number ++} ++ ++export type WslServerAcknowledgements = { ++ root: boolean ++ mismatch: { path: string; version: string } | null ++} ++ ++export type WslServerConfig = { ++ id: string ++ distro: string ++ acknowledgements: WslServerAcknowledgements ++} ++ ++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 ++ opencodeChecks: Record ++ pendingRestart: boolean ++ servers: WslServerItem[] ++ job: WslJob | null ++ transcript: WslTranscriptLine[] ++ lastError: string | null ++} ++export type WslServersEvent = { type: "state"; state: WslServersState } ++ ++export type WslServersAPI = { ++ getState: () => Promise ++ subscribe: (cb: (event: WslServersEvent) => void) => () => void ++ probeRuntime: () => Promise ++ refreshDistros: () => Promise ++ installWsl: () => Promise ++ installDistro: (name: string) => Promise ++ probeDistro: (name: string) => Promise ++ probeOpencode: (name: string) => Promise ++ installOpencode: (name: string) => Promise ++ openTerminal: (name: string) => Promise ++ addServer: (distro: string) => Promise ++ removeServer: (id: string) => Promise ++ startServer: (id: string) => Promise ++ stopServer: (id: string) => Promise ++ cancelJob: () => Promise ++ updateAcknowledgements: (id: string, acks: Partial) => Promise ++} + + export type LinuxDisplayBackend = "wayland" | "auto" + export type TitlebarTheme = { +@@ -19,15 +128,14 @@ export type ElectronAPI = { + killSidecar: () => Promise + installCli: () => Promise + awaitInitialization: (onStep: (step: InitStep) => void) => Promise ++ wslServers: WslServersAPI + getDefaultServerUrl: () => Promise + setDefaultServerUrl: (url: string | null) => Promise +- getWslConfig: () => Promise +- setWslConfig: (config: WslConfig) => Promise + getDisplayBackend: () => Promise + setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise + parseMarkdownCommand: (markdown: string) => Promise + checkAppExists: (appName: string) => Promise +- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise ++ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise + resolveAppPath: (appName: string) => Promise + storeGet: (name: string, key: string) => Promise + storeSet: (name: string, key: string, value: string) => Promise +diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts +index d1590ff048..3dbd50f61a 100644 +--- a/packages/desktop-electron/src/renderer/env.d.ts ++++ b/packages/desktop-electron/src/renderer/env.d.ts +@@ -5,8 +5,8 @@ declare global { + api: ElectronAPI + __OPENCODE__?: { + updaterEnabled?: boolean +- wsl?: boolean + deepLinks?: string[] ++ activeServer?: string + } + } + } +diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx +index 44f2e6360c..7aae903485 100644 +--- a/packages/desktop-electron/src/renderer/index.tsx ++++ b/packages/desktop-electron/src/renderer/index.tsx +@@ -1,5 +1,57 @@ + // @refresh reload + ++// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so ++// reported errors come with a useful frame budget. ++Error.stackTraceLimit = 200 ++ ++// Install global error listeners before any other module runs so that ++// uncaught errors and rejected promises reach the main process with their ++// full stacks intact. Electron's `console-message` event only forwards the ++// rethrow site, so without these we lose the originating frame. ++window.addEventListener("error", (event) => { ++ const err = event.error ++ const stack = err instanceof Error ? err.stack : null ++ console.error( ++ "[renderer uncaught]", ++ stack ?? event.message, ++ stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`, ++ ) ++}) ++ ++window.addEventListener("unhandledrejection", (event) => { ++ const reason = event.reason ++ // Log as much as possible: stack for Errors, JSON for plain objects with ++ // a fallback to a tagged shape so we never end up with just ++ // "[object Object]" in main.log. ++ if (reason instanceof Error) { ++ console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason)) ++ return ++ } ++ let serialized: string ++ try { ++ serialized = JSON.stringify( ++ reason, ++ (_key, value) => { ++ if (value instanceof Error) { ++ return { __error: true, name: value.name, message: value.message, stack: value.stack } ++ } ++ return value ++ }, ++ 2, ++ ) ++ } catch { ++ serialized = String(reason) ++ } ++ console.error( ++ "[renderer unhandled rejection]", ++ `type=${typeof reason}`, ++ `ctor=${reason?.constructor?.name ?? "null"}`, ++ `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`, ++ "value:", ++ serialized, ++ ) ++}) ++ + import { + ACCEPTED_FILE_EXTENSIONS, + ACCEPTED_FILE_TYPES, +@@ -13,16 +65,20 @@ import { + PlatformProvider, + ServerConnection, + useCommand, ++ type WslServersEvent, ++ type WslServersState, + } from "@opencode-ai/app" + import type { AsyncStorage } from "@solid-primitives/storage" + import { MemoryRouter } from "@solidjs/router" +-import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" ++import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" + import { render } from "solid-js/web" + import pkg from "../../package.json" + import { initI18n, t } from "./i18n" + import { UPDATER_ENABLED } from "./updater" +-import { webviewZoom } from "./webview-zoom" ++import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom" + import "./styles.css" ++import { Button } from "@opencode-ai/ui/button" ++import { Splash } from "@opencode-ai/ui/logo" + import { useTheme } from "@opencode-ai/ui/theme" + + const root = document.getElementById("root") +@@ -48,6 +104,21 @@ const listenForDeepLinks = () => { + return window.api.onDeepLink((urls) => emitDeepLinks(urls)) + } + ++function LocalServerStartupError(props: { message: string }) { ++ return ( ++
++
++ ++

Local Server failed to start

++

{props.message}

++ ++
++
++ ) ++} ++ + const createPlatform = (): Platform => { + const os = (() => { + const ua = navigator.userAgent +@@ -57,17 +128,25 @@ const createPlatform = (): Platform => { + return undefined + })() + ++ const activeWslDistro = () => { ++ const key = window.__OPENCODE__?.activeServer ++ if (!key || !key.startsWith("wsl:")) return undefined ++ return key.slice("wsl:".length) ++ } ++ + const wslHome = async () => { +- if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined +- return window.api.wslPath("~", "windows").catch(() => undefined) ++ const distro = activeWslDistro() ++ if (!distro) return undefined ++ return window.api.wslPath("~", "windows", distro).catch(() => undefined) + } + + const handleWslPicker = async (result: T | null): Promise => { +- if (!result || !window.__OPENCODE__?.wsl) return result ++ const distro = activeWslDistro() ++ if (!result || !distro) return result + if (Array.isArray(result)) { +- return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any ++ return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any + } +- return window.api.wslPath(result, "linux").catch(() => result) as any ++ return window.api.wslPath(result, "linux", distro).catch(() => result) as any + } + + const storage = (() => { +@@ -97,6 +176,8 @@ const createPlatform = (): Platform => { + } + })() + ++ const wslServersApi = os === "windows" ? window.api.wslServers : undefined ++ + return { + platform: "desktop", + os, +@@ -137,8 +218,9 @@ const createPlatform = (): Platform => { + if (os === "windows") { + const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null + const resolvedPath = await (async () => { +- if (window.__OPENCODE__?.wsl) { +- const converted = await window.api.wslPath(path, "windows").catch(() => null) ++ const distro = activeWslDistro() ++ if (distro) { ++ const converted = await window.api.wslPath(path, "windows", distro).catch(() => null) + if (converted) return converted + } + return path +@@ -194,16 +276,6 @@ const createPlatform = (): Platform => { + return fetch(input, init) + }, + +- getWslEnabled: async () => { +- const next = await window.api.getWslConfig().catch(() => null) +- if (next) return next.enabled +- return window.__OPENCODE__!.wsl ?? false +- }, +- +- setWslEnabled: async (enabled) => { +- await window.api.setWslConfig({ enabled }) +- }, +- + getDefaultServer: async () => { + const url = await window.api.getDefaultServerUrl().catch(() => null) + if (!url) return null +@@ -214,6 +286,8 @@ const createPlatform = (): Platform => { + await window.api.setDefaultServerUrl(url) + }, + ++ wslServers: wslServersApi, ++ + getDisplayBackend: async () => { + return window.api.getDisplayBackend().catch(() => null) + }, +@@ -243,6 +317,9 @@ const createPlatform = (): Platform => { + + let menuTrigger = null as null | ((id: string) => void) + window.api.onMenuCommand((id) => { ++ if (id === "zoom.in") return zoomIn() ++ if (id === "zoom.out") return zoomOut() ++ if (id === "zoom.reset") return zoomReset() + menuTrigger?.(id) + }) + listenForDeepLinks() +@@ -263,8 +340,19 @@ render(() => { + + const [windowCount] = createResource(() => window.api.getWindowCount()) + +- // Fetch sidecar credentials (available immediately, before health check) +- const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) ++ const [startup] = createResource(async () => { ++ try { ++ return { ++ error: null, ++ sidecar: await window.api.awaitInitialization(() => undefined), ++ } ++ } catch (error) { ++ return { ++ error: error instanceof Error ? error.message : String(error), ++ sidecar: null, ++ } ++ } ++ }) + + const [defaultServer] = createResource(() => + platform.getDefaultServer?.().then((url) => { +@@ -273,20 +361,52 @@ render(() => { + ) + const [locale] = createResource(loadLocale) + ++ const [wslServers, setWslServers] = createSignal(null) ++ if (platform.wslServers) { ++ void platform.wslServers.getState().then((state) => setWslServers(state)) ++ const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) ++ onCleanup(off) ++ } ++ + const servers = () => { +- const data = 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, +- }, ++ const data = startup.latest?.sidecar ++ const list: ServerConnection.Any[] = [] ++ if (data) { ++ list.push({ ++ displayName: "Local Server", ++ type: "sidecar", ++ variant: "base", ++ http: { ++ url: data.local.url, ++ username: data.local.username ?? undefined, ++ password: data.local.password ?? undefined, ++ }, ++ }) ++ } ++ const wsl = wslServers() ++ if (wsl) { ++ for (const item of wsl.servers) { ++ const runtime = item.runtime ++ const http = ++ runtime.kind === "ready" ++ ? { ++ url: runtime.url, ++ username: runtime.username ?? undefined, ++ password: runtime.password ?? undefined, ++ } ++ : { ++ url: `http://wsl-${item.config.distro}.invalid`, ++ } ++ list.push({ ++ displayName: `WSL: ${item.config.distro}`, ++ type: "sidecar", ++ variant: "wsl", ++ distro: item.config.distro, ++ http, ++ }) ++ } + } +- return [server] as ServerConnection.Any[] ++ return list + } + + function handleClick(e: MouseEvent) { +@@ -325,11 +445,17 @@ render(() => { + return ( + + +- ++ + {(_) => { ++ if (startup.latest?.error) { ++ return ++ } + return ( + +diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts +index 9c0a3a3a35..6ff35e2459 100644 +--- a/packages/desktop-electron/src/renderer/webview-zoom.ts ++++ b/packages/desktop-electron/src/renderer/webview-zoom.ts +@@ -11,28 +11,73 @@ const OS_NAME = (() => { + return "unknown" + })() + +-const [webviewZoom, setWebviewZoom] = createSignal(1) ++const MIN_ZOOM = 0.2 ++const MAX_ZOOM = 10 ++const KEY_STEP = 0.2 ++const WHEEL_STEP = 0.1 + +-const MAX_ZOOM_LEVEL = 10 +-const MIN_ZOOM_LEVEL = 0.2 ++const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM) + +-const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) ++const [webviewZoom, setWebviewZoom] = createSignal(1) + +-const applyZoom = (next: number) => { +- setWebviewZoom(next) +- void window.api.setZoomFactor(next) ++const apply = (next: number) => { ++ const clamped = clamp(next) ++ if (Math.abs(clamped - webviewZoom()) < 1e-6) return ++ setWebviewZoom(clamped) ++ void window.api.setZoomFactor(clamped).catch(() => undefined) + } + +-window.addEventListener("keydown", (event) => { +- if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return ++export const zoomIn = () => apply(webviewZoom() + KEY_STEP) ++export const zoomOut = () => apply(webviewZoom() - KEY_STEP) ++export const zoomReset = () => apply(1) + +- let newZoom = webviewZoom() ++// Seed the signal from the main process so renderer and webContents agree ++// across cold starts, reloads, and HMR refreshes (which would otherwise ++// reinitialize the signal to 1 while webContents kept its prior factor). ++void window.api ++ .getZoomFactor() ++ .then((initial) => { ++ if (typeof initial === "number" && Number.isFinite(initial)) { ++ setWebviewZoom(clamp(initial)) ++ } ++ }) ++ .catch(() => undefined) + +- if (event.key === "-") newZoom -= 0.2 +- if (event.key === "=" || event.key === "+") newZoom += 0.2 +- if (event.key === "0") newZoom = 1 ++// Keyboard accelerators. preventDefault stops Chromium's built-in zoom ++// accelerators from firing in parallel (which previously caused races). ++window.addEventListener("keydown", (event) => { ++ const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey ++ if (!mod || event.altKey) return + +- applyZoom(clamp(newZoom)) ++ if (event.key === "-" || event.key === "_") { ++ event.preventDefault() ++ zoomOut() ++ return ++ } ++ if (event.key === "=" || event.key === "+") { ++ event.preventDefault() ++ zoomIn() ++ return ++ } ++ if (event.key === "0") { ++ event.preventDefault() ++ zoomReset() ++ return ++ } + }) + ++// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad ++// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom ++// as well as real ctrl+scroll / cmd+scroll. ++window.addEventListener( ++ "wheel", ++ (event) => { ++ if (!event.ctrlKey && !event.metaKey) return ++ event.preventDefault() ++ const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP ++ apply(webviewZoom() + step) ++ }, ++ { passive: false }, ++) ++ + export { webviewZoom } +diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx +index d6a0ad74f8..875c0bcd60 100644 +--- a/packages/desktop/src/index.tsx ++++ b/packages/desktop/src/index.tsx +@@ -485,7 +485,7 @@ render(() => { + {(_) => { + return ( + + +diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css +index 1e74763ae2..db6c750f9b 100644 +--- a/packages/ui/src/components/dialog.css ++++ b/packages/ui/src/components/dialog.css +@@ -35,7 +35,7 @@ + width: 100%; + max-height: 100%; + min-height: 280px; +- overflow: auto; ++ overflow: hidden; + pointer-events: auto; + + /* Hide scrollbar */ +@@ -102,7 +102,8 @@ + display: flex; + flex-direction: column; + flex: 1; +- overflow: hidden; ++ min-height: 0; ++ overflow-y: auto; + + &:focus-visible { + outline: none; +diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx +index 981e3f45d7..39003f3b68 100644 +--- a/packages/ui/src/components/dialog.tsx ++++ b/packages/ui/src/components/dialog.tsx +@@ -1,6 +1,7 @@ + import { Dialog as Kobalte } from "@kobalte/core/dialog" +-import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" ++import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" + import { useI18n } from "../context/i18n" ++import { DialogContext } from "../context/dialog" + import { IconButton } from "./icon-button" + + export interface DialogProps extends ParentProps { +@@ -12,10 +13,19 @@ export interface DialogProps extends ParentProps { + classList?: ComponentProps<"div">["classList"] + fit?: boolean + transition?: boolean ++ // When `false`, clicking the overlay or outside the dialog will not dismiss it. ++ // Default is `true`. ++ dismissOutside?: boolean + } + + export function Dialog(props: DialogProps) { + const i18n = useI18n() ++ const dialogCtx = useContext(DialogContext) ++ createEffect(() => { ++ if (!dialogCtx) return ++ if (props.dismissOutside === undefined) return ++ dialogCtx.active?.setDismissOutside(props.dismissOutside) ++ }) + return ( +
void + setClosing: (closing: boolean) => void ++ dismissOutside: () => boolean ++ setDismissOutside: (value: boolean) => void + } + + const Context = createContext>() + ++export const DialogContext = Context ++ + function init() { + const [active, setActive] = createSignal() + const timer = { current: undefined as ReturnType | undefined } +@@ -89,12 +93,17 @@ function init() { + const id = Math.random().toString(36).slice(2) + let dispose: (() => void) | undefined + let setClosing: ((closing: boolean) => void) | undefined ++ let setDismissOutsideSignal: ((value: boolean) => void) | undefined ++ let dismissOutsideAccessor: (() => boolean) | undefined + + const node = runWithOwner(owner, () => + createRoot((d: () => void) => { + dispose = d + const [closing, setClosingSignal] = createSignal(false) + setClosing = setClosingSignal ++ const [dismissOutside, setDismissOutside] = createSignal(true) ++ dismissOutsideAccessor = dismissOutside ++ setDismissOutsideSignal = setDismissOutside + return ( + + +- ++ { ++ if (dismissOutside()) close() ++ }} ++ /> + {element()} + + +@@ -113,9 +127,18 @@ function init() { + }), + ) + +- if (!dispose || !setClosing) return +- +- setActive({ id, node, dispose, owner, onClose, setClosing }) ++ if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return ++ ++ setActive({ ++ id, ++ node, ++ dispose, ++ owner, ++ onClose, ++ setClosing, ++ dismissOutside: dismissOutsideAccessor, ++ setDismissOutside: setDismissOutsideSignal, ++ }) + } + + return { +@@ -159,5 +182,8 @@ export function useDialog() { + close() { + ctx.close() + }, ++ setDismissOutside(value: boolean) { ++ ctx.active?.setDismissOutside(value) ++ }, + } + } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 93eaf0df49c1..9fafcc1cd97d 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -13,11 +13,36 @@ import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" +import type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" +const cachedServerStatus = new Map() + +function versionOlderThan(current: string | null | undefined, expected: string | null | undefined) { + if (!current || !expected) return false + + const parse = (value: string) => { + const match = value.match(/v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/) + if (!match) return + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + } + } + + const left = parse(current) + const right = parse(expected) + if (!left || !right) return false + if (left.major !== right.major) return left.major < right.major + if (left.minor !== right.minor) return left.minor < right.minor + if (left.patch !== right.patch) return left.patch < right.patch + return !!left.prerelease && !right.prerelease +} interface DialogSelectServerProps { initialView?: "list" | "add-wsl" @@ -186,6 +211,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const checkServerHealth = useCheckServerHealth() const [store, setStore] = createStore({ status: {} as Record, + wslState: undefined as WslServersState | undefined, addServer: { url: "", name: "", @@ -319,6 +345,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + const healthPollKey = createMemo(() => + items() + .map((conn) => + [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"), + ) + .join("\n\n"), + ) + const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) const sortedItems = createMemo(() => { const list = items() @@ -333,7 +367,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) + const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b))) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) @@ -346,27 +380,74 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) + for (const [key, value] of Object.entries(results)) { + cachedServerStatus.set(ServerConnection.Key.make(key), value) + } setStore("status", reconcile(results)) } createEffect(() => { - items() + healthPollKey() void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) + createEffect(() => { + const api = platform.wslServers + if (!api) return + let dead = false + void api + .getState() + .then((state) => { + if (dead) return + setStore("wslState", reconcile(state)) + }) + .catch((err) => { + if (dead) return + showRequestError(language, err) + }) + const off = api.subscribe((event) => { + setStore("wslState", reconcile(event.state)) + }) + onCleanup(() => { + dead = true + off() + }) + }) + + const wslCheck = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return null + return store.wslState?.opencodeChecks[conn.distro] ?? null + } + + const displayVersion = (conn: ServerConnection.Any) => { + if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined + return undefined + } + async function select(conn: ServerConnection.Any, persist?: boolean) { if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return dialog.close() + const nextKey = ServerConnection.key(conn) + const changed = server.key !== nextKey + if (persist && conn.type === "http") { server.add(conn) - props.onNavigateHome?.() + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } return } batch(() => { - props.onNavigateHome?.() - server.setActive(ServerConnection.key(conn)) + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } + server.setActive(nextKey) }) } @@ -552,6 +633,18 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } } + async function handleUpdateWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + const api = platform.wslServers + if (!api) return + try { + await api.installOpencode(conn.distro) + await refreshHealth() + } catch (err) { + showRequestError(language, err) + } + } + return (
@@ -601,15 +694,32 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { {(i) => { const key = ServerConnection.key(i) const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" + const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined + const hasMenuActionsBeforeDelete = () => + i.type === "http" || (isWslSidecar && health(key)?.healthy === false) + const outdated = () => { + const check = wslCheck(i) + return versionOlderThan(check?.version, check?.expectedVersion) + } + const opencodeAction = () => { + const check = wslCheck(i) + if (!check) return null + if (!check.resolvedPath) return "Install OpenCode" + if (outdated()) return "Update OpenCode" + return null + } + const updating = () => + store.wslState?.job?.kind === "install-opencode" && store.wslState.job.distro === wslDistro return (
- +
@@ -621,6 +731,23 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
+ + {(label) => ( + + )} + @@ -647,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { {language.t("dialog.server.menu.edit")} - + void handleRetryWsl(i)}> Retry start @@ -666,8 +793,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { - + + + (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} class="text-text-on-critical-base hover:bg-surface-critical-weak" diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index d971051eec50..a396098a21f1 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -10,6 +10,18 @@ import { usePlatform } from "@/context/platform" const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"] +function isHiddenDistro(name: string) { + return /^docker-desktop(?:-data)?$/i.test(name) +} + +function parseProgressPercent(text: string) { + const match = text.match(/(\d{1,3}(?:[.,]\d+)?)\s*%/) + if (!match) return null + const value = Number.parseFloat(match[1]!.replace(",", ".")) + if (!Number.isFinite(value)) return null + return Math.max(0, Math.min(99, Math.floor(value))) +} + interface DialogWslServerProps { onAdded?: () => void } @@ -66,7 +78,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { if (!distro) return null return (current()?.installed ?? []).find((item) => item.name === distro) ?? null }) - const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null) + 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 opencodeCheck = createMemo(() => { const distro = selectedDistro() if (!distro) return null @@ -97,17 +113,27 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { }) const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro))) const addableInstalledDistros = createMemo(() => { - return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name)) + return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name)) }) const installableDistros = createMemo(() => { - const online = current()?.online ?? [] - const installed = new Set((current()?.installed ?? []).map((item) => item.name)) + 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) ?? null) + const installingDistro = createMemo(() => current()?.job?.kind === "install-distro") + const installDistroPercent = createMemo(() => { + if (!installingDistro()) return null + const transcript = current()?.transcript ?? [] + for (let i = transcript.length - 1; i >= 0; i--) { + const percent = parseProgressPercent(transcript[i]!.text) + if (percent !== null) return percent + } + return null + }) const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() @@ -284,12 +310,12 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { return (
Loading...
}> -
+
{(item) => ( +
+ + + {installDistroPercent()}% + + + + + + +
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 cad0b0673a75..1034f0676d58 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 edbbd752c957..4997a16bda03 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 e53d60d5a0ea..973ecc66bc24 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 096ef23db91a..f6e94963e6c0 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 6e07e0312412..e33893ba77b1 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 482f55c7163e..c1f394166007 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 a13fd34ef728..1b054c4ffd89 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 eb0b260ea96e..0091f35e7db9 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 4ee71ba2a5cc..a89ce6fd2f49 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 c6d2c4face98..54ef704927c1 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 70e3dc9c7503..2c0b25b6adfd 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 8bfd19ef2029..8d65ee4aad9e 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 61f0c0a4938c..b65f20a8555f 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 000000000000..7f8bdfa6d5e2 --- /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 c35e4e52bf51..9c9dc79e89f1 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 07a22b8252ab..1785df0f13d6 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 faf0d692cb43..ea8675a87754 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 18183868ad32..98e61a66cfaa 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 7aae90348572..f7342e9bea4e 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 070d0c71f4f2..d6ed08644505 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), } } From 6ae05c541df5c7299493f0ca1c5b96841aed2ca7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:23:24 +1000 Subject: [PATCH 64/88] fix(desktop): remove deprecated wsl platform properties from tauri --- packages/desktop/src/index.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 875c0bcd606b..b8965b9f4fc5 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -70,12 +70,16 @@ const createPlatform = (): Platform => { })() const wslHome = async () => { - if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + if (os !== "windows") return undefined + const wsl = await commands.getWslConfig().catch(() => null) + if (!wsl?.enabled) return undefined return commands.wslPath("~", "windows").catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__OPENCODE__?.wsl) return result + if (!result) return result + const wsl = await commands.getWslConfig().catch(() => null) + if (!wsl?.enabled) return result if (Array.isArray(result)) { return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any } @@ -343,16 +347,6 @@ const createPlatform = (): Platform => { } }, - getWslEnabled: async () => { - const next = await commands.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__OPENCODE__!.wsl ?? false - }, - - setWslEnabled: async (enabled) => { - await commands.setWslConfig({ enabled }) - }, - getDefaultServer: async () => { const url = await commands.getDefaultServerUrl().catch(() => null) if (!url) return null From 796991b3ea62c28ef20816d20457843162cbe029 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:37:28 +1000 Subject: [PATCH 65/88] oops --- diff.txt | 4594 ------------------------------------------------------ 1 file changed, 4594 deletions(-) delete mode 100644 diff.txt diff --git a/diff.txt b/diff.txt deleted file mode 100644 index 351288c8f623..000000000000 --- a/diff.txt +++ /dev/null @@ -1,4594 +0,0 @@ -diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx -index dbe1074484..5528523ab9 100644 ---- a/packages/app/src/app.tsx -+++ b/packages/app/src/app.tsx -@@ -1,5 +1,7 @@ - import "@/index.css" -+import { Button } from "@opencode-ai/ui/button" - import { I18nProvider } from "@opencode-ai/ui/context" -+import { useDialog } from "@opencode-ai/ui/context/dialog" - import { DialogProvider } from "@opencode-ai/ui/context/dialog" - import { FileComponentProvider } from "@opencode-ai/ui/context/file" - import { MarkedProvider } from "@opencode-ai/ui/context/marked" -@@ -26,6 +28,7 @@ import { - Suspense, - } from "solid-js" - import { Dynamic } from "solid-js/web" -+import { serverSwitching } from "@/utils/server-switch" - import { CommandProvider } from "@/context/command" - import { CommentsProvider } from "@/context/comments" - import { FileProvider } from "@/context/file" -@@ -37,6 +40,7 @@ import { LayoutProvider } from "@/context/layout" - import { ModelsProvider } from "@/context/models" - import { NotificationProvider } from "@/context/notification" - import { PermissionProvider } from "@/context/permission" -+import { usePlatform } from "@/context/platform" - import { PromptProvider } from "@/context/prompt" - import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" - import { SettingsProvider } from "@/context/settings" -@@ -73,7 +77,7 @@ declare global { - __OPENCODE__?: { - updaterEnabled?: boolean - deepLinks?: string[] -- wsl?: boolean -+ activeServer?: string - } - api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise -@@ -223,12 +227,15 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { - } - - function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { -+ const dialog = useDialog() - const language = useLanguage() -+ const platform = usePlatform() - const server = useServer() - const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) - const name = createMemo(() => server.name || server.key) - const serverToken = "\u0000server\u0000" - const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) -+ const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") - - const timer = setInterval(() => props.onRetry?.(), 1000) - onCleanup(() => clearInterval(timer)) -@@ -243,6 +250,34 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: - {unreachable()[1]} -

-

{language.t("app.server.retrying")}

-+ -+ -+ -
- 0}> -
-@@ -285,6 +320,12 @@ export function AppInterface(props: { - router?: Component - disableHealthCheck?: boolean - }) { -+ // ServerKey wraps the whole Router so that switching `server.key` throws -+ // away any session / pty state from the previous server. Preserving the -+ // route across servers doesn't work because session ids, pty ids, and -+ // most URL-addressable resources are server-scoped — you'd 404 on every -+ // fetch. The click handler that swaps servers also navigates back to "/" -+ // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL. - return ( - - -+ -+
-+ -+
-+
- - - -diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx -index dd92edec3e..93eaf0df49 100644 ---- a/packages/app/src/components/dialog-select-server.tsx -+++ b/packages/app/src/components/dialog-select-server.tsx -@@ -8,9 +8,9 @@ import { List } from "@opencode-ai/ui/list" - import { TextField } from "@opencode-ai/ui/text-field" - import { useMutation } from "@tanstack/solid-query" - import { showToast } from "@opencode-ai/ui/toast" --import { useNavigate } from "@solidjs/router" --import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" -+import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" - import { createStore, reconcile } from "solid-js/store" -+import { DialogWslServer } from "@/components/dialog-wsl-server" - import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" - import { useLanguage } from "@/context/language" - import { usePlatform } from "@/context/platform" -@@ -19,6 +19,11 @@ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" - - const DEFAULT_USERNAME = "opencode" - -+interface DialogSelectServerProps { -+ initialView?: "list" | "add-wsl" -+ onNavigateHome?: () => void -+} -+ - interface ServerFormProps { - value: string - name: string -@@ -171,8 +176,7 @@ function ServerForm(props: ServerFormProps) { - ) - } - --export function DialogSelectServer() { -- const navigate = useNavigate() -+export function DialogSelectServer(props: DialogSelectServerProps = {}) { - const dialog = useDialog() - const server = useServer() - const platform = usePlatform() -@@ -191,6 +195,9 @@ export function DialogSelectServer() { - showForm: false, - status: undefined as boolean | undefined, - }, -+ addWsl: { -+ showWizard: props.initialView === "add-wsl", -+ }, - editServer: { - id: undefined as string | undefined, - value: "", -@@ -354,11 +361,13 @@ export function DialogSelectServer() { - dialog.close() - if (persist && conn.type === "http") { - server.add(conn) -- navigate("/") -+ props.onNavigateHome?.() - return - } -- navigate("/") -- queueMicrotask(() => server.setActive(ServerConnection.key(conn))) -+ batch(() => { -+ props.onNavigateHome?.() -+ server.setActive(ServerConnection.key(conn)) -+ }) - } - - const handleAddChange = (value: string) => { -@@ -419,7 +428,8 @@ export function DialogSelectServer() { - ) - } - -- const mode = createMemo<"list" | "add" | "edit">(() => { -+ const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => { -+ if (store.addWsl.showWizard) return "add-wsl" - if (store.editServer.id) return "edit" - if (store.addServer.showForm) return "add" - return "list" -@@ -433,9 +443,11 @@ export function DialogSelectServer() { - const resetForm = () => { - resetAdd() - resetEdit() -+ setStore("addWsl", "showWizard", false) - } - - const startAdd = () => { -+ setStore("addWsl", "showWizard", false) - resetEdit() - setStore("addServer", { - showForm: true, -@@ -449,6 +461,7 @@ export function DialogSelectServer() { - } - - const startEdit = (conn: ServerConnection.Http) => { -+ setStore("addWsl", "showWizard", false) - resetAdd() - setStore("editServer", { - id: conn.http.url, -@@ -461,6 +474,12 @@ export function DialogSelectServer() { - }) - } - -+ const startAddWsl = () => { -+ resetAdd() -+ resetEdit() -+ setStore("addWsl", "showWizard", true) -+ } -+ - const submitForm = () => { - if (mode() === "add") { - if (addMutation.isPending) return -@@ -477,14 +496,22 @@ export function DialogSelectServer() { - - const isFormMode = createMemo(() => mode() !== "list") - const isAddMode = createMemo(() => mode() === "add") -+ const isAddWslMode = createMemo(() => mode() === "add-wsl") - const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) -+ const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows") - - const formTitle = createMemo(() => { - if (!isFormMode()) return language.t("dialog.server.title") - return ( -
- -- {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} -+ -+ {isAddWslMode() -+ ? "Add WSL server" -+ : isAddMode() -+ ? language.t("dialog.server.add.title") -+ : language.t("dialog.server.edit.title")} -+ -
- ) - }) -@@ -495,35 +522,65 @@ export function DialogSelectServer() { - resetEdit() - }) - -- async function handleRemove(url: ServerConnection.Key) { -- server.remove(url) -- if ((await platform.getDefaultServer?.()) === url) { -+ async function handleRemove(key: ServerConnection.Key) { -+ server.remove(key) -+ if ((await platform.getDefaultServer?.()) === key) { - void platform.setDefaultServer?.(null) - } - } - -+ async function handleRemoveWsl(conn: ServerConnection.Any) { -+ if (conn.type !== "sidecar" || conn.variant !== "wsl") return -+ const key = ServerConnection.key(conn) -+ try { -+ await platform.wslServers?.removeServer(key) -+ server.remove(key) -+ if ((await platform.getDefaultServer?.()) === key) { -+ void platform.setDefaultServer?.(null) -+ } -+ } catch (err) { -+ showRequestError(language, err) -+ } -+ } -+ -+ async function handleRetryWsl(conn: ServerConnection.Any) { -+ if (conn.type !== "sidecar" || conn.variant !== "wsl") return -+ try { -+ await platform.wslServers?.startServer(ServerConnection.key(conn)) -+ } catch (err) { -+ showRequestError(language, err) -+ } -+ } -+ - return ( -- -+ -
- -+ -+ } -+ > -+ -+ - } - > - x.http.url} -+ key={(x) => ServerConnection.key(x)} - onSelect={(x) => { - if (x) void select(x) - }} -@@ -543,6 +600,7 @@ export function DialogSelectServer() { - > - {(i) => { - const key = ServerConnection.key(i) -+ const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" - return ( -
-
-@@ -562,12 +620,12 @@ export function DialogSelectServer() { - } - showCredentials - /> --
-+
- - - - -- -+ - - - - -- { -- if (i.type !== "http") return -- startEdit(i) -- }} -- > -- {language.t("dialog.server.menu.edit")} -- -- -+ -+ { -+ if (i.type !== "http") return -+ startEdit(i) -+ }} -+ > -+ {language.t("dialog.server.menu.edit")} -+ -+ -+ -+ void handleRetryWsl(i)}> -+ Retry start -+ -+ -+ - setDefault(key)}> - - {language.t("dialog.server.menu.default")} - - - -- -+ - setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - -- -- handleRemove(ServerConnection.key(i))} -- class="text-text-on-critical-base hover:bg-surface-critical-weak" -- > -- {language.t("dialog.server.menu.delete")} -- -+ -+ -+ (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} -+ class="text-text-on-critical-base hover:bg-surface-critical-weak" -+ > -+ -+ {language.t("dialog.server.menu.delete")} -+ -+ -+ - - - -@@ -621,17 +690,32 @@ export function DialogSelectServer() { - -
- -- {language.t("dialog.server.add.button")} -- -+ -+
-+ -+ -+ -+ -+
-+
- } - > - -+ )} -+ -+
-+ -+ -+ -+
-+
-+
WSL
-+ -+ -+ -+
-+
{wslMessage()}
-+ -+
-+
Windows restart required.
-+ -+
-+
-+
-+
-+ -+ -+
-+
Choose a distro
-+
{distroMessage()}
-+ -+
-+ 0} -+ fallback={ -+
-+ {current()?.installed.length -+ ? "All installed distros are already added." -+ : current()?.runtime?.available -+ ? "No distros detected yet." -+ : "Checking distros..."} -+
-+ } -+ > -+ -+ {(item) => ( -+ -+ )} -+ -+
-+
-+ -+ 0}> -+
-+
-+
Install
-+ -+
-+
-+ -+ {(item) => { -+ const selected = () => store.installTarget === item.name -+ return ( -+ -+ ) -+ }} -+ -+
-+
-+
-+ -+ -+
-+ -+
WSL 2 is required.
-+
-+ -+ {(message) =>
{message()}
} -+
-+ -+
This distro needs bash and curl.
-+
-+ -+
-+ This distro is using the root user right now. -+
-+
-+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+
OpenCode
-+ -+ -+ -+
-+
{opencodeMessage()}
-+ -+ {(check) => ( -+
-+
Path: {check().resolvedPath ?? "not found"}
-+
-+ Version: {check().version ?? "unknown"} -+ -+ {(expected) => {` · desktop ${expected()}`}} -+ -+
-+
-+ Installed version does not match the desktop app version. -+
-+
-+ )} -+
-+
-+
-+
-+ -+ -+ {(progress) => ( -+
-+
-+ -+
Progress
-+
-+
{progress().title}
-+
-+ -+ {(line) => ( -+
-+ {line.text} -+
-+ )} -+
-+
-+
-+ )} -+
-+ -+ 0}> -+
-+
Diagnostics
-+
-+ {(line) =>
{line.text}
}
-+
-+
-+
-+ -+
-+ -+ -+
-+
-+
-+ ) -+} -+ -+function requestError(language: ReturnType, 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), -+ }) -+} -+ -+function stepIndex(step: WslServerStep) { -+ return STEPS.indexOf(step) -+} -+ -+function stepTitle(step: WslServerStep) { -+ if (step === "wsl") return "WSL" -+ if (step === "distro") return "Choose distro" -+ return "OpenCode" -+} -+ -+function stepState( -+ step: WslServerStep, -+ state: { -+ active: WslServerStep -+ wslReady: boolean -+ distroReady: boolean -+ opencodeReady: boolean -+ opencodeMismatch: boolean -+ }, -+) { -+ if (state.active === step) return "current" -+ if (step === "wsl") return state.wslReady ? "done" : "warning" -+ if (step === "distro") -+ return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" -+ return state.opencodeMismatch -+ ? "warning" -+ : state.opencodeReady -+ ? "done" -+ : stepIndex(step) > stepIndex(state.active) -+ ? "locked" -+ : "warning" -+} -diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx -index 021e5be67e..3b4bda7f27 100644 ---- a/packages/app/src/components/session/session-header.tsx -+++ b/packages/app/src/components/session/session-header.tsx -@@ -6,9 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button" - import { Keybind } from "@opencode-ai/ui/keybind" - import { Spinner } from "@opencode-ai/ui/spinner" - import { showToast } from "@opencode-ai/ui/toast" -+import { StatusPopover } from "../status-popover" - import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" - import { getFilename } from "@opencode-ai/shared/util/path" --import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" -+import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" - import { createStore } from "solid-js/store" - import { Portal } from "solid-js/web" - import { useCommand } from "@/context/command" -@@ -24,7 +25,6 @@ import { useSessionLayout } from "@/pages/session/session-layout" - import { messageAgentColor } from "@/utils/agent" - import { decode64 } from "@/utils/base64" - import { Persist, persisted } from "@/utils/persist" --import { StatusPopover } from "../status-popover" - - const OPEN_APPS = [ - "vscode", -@@ -129,6 +129,13 @@ const showRequestError = (language: ReturnType, err: unknown - }) - } - -+function titlebarMounts() { -+ return { -+ center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined, -+ right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined, -+ } -+} -+ - export function SessionHeader() { - const layout = useLayout() - const command = useCommand() -@@ -219,6 +226,7 @@ export function SessionHeader() { - const [openRequest, setOpenRequest] = createStore({ - app: undefined as OpenApp | undefined, - }) -+ const [mounts, setMounts] = createStore(titlebarMounts()) - - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo( -@@ -232,6 +240,19 @@ export function SessionHeader() { - messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), - ) - -+ const syncMounts = () => { -+ const next = titlebarMounts() -+ if (mounts.center === next.center && mounts.right === next.right) return -+ setMounts(next) -+ } -+ -+ onMount(() => { -+ syncMounts() -+ const observer = new MutationObserver(() => syncMounts()) -+ observer.observe(document.body, { childList: true, subtree: true }) -+ onCleanup(() => observer.disconnect()) -+ }) -+ - const selectApp = (app: OpenApp) => { - if (!options().some((item) => item.id === app)) return - setPrefs("app", app) -@@ -269,12 +290,8 @@ export function SessionHeader() { - .catch((err: unknown) => showRequestError(language, err)) - } - -- const [centerMount, setCenterMount] = createSignal(null) -- const [rightMount, setRightMount] = createSignal(null) -- onMount(() => { -- setCenterMount(document.getElementById("opencode-titlebar-center")) -- setRightMount(document.getElementById("opencode-titlebar-right")) -- }) -+ const centerMount = createMemo(() => mounts.center) -+ const rightMount = createMemo(() => mounts.right) - - return ( - <> -diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx -index 0f6a1c1355..cad0b0673a 100644 ---- a/packages/app/src/components/status-popover-body.tsx -+++ b/packages/app/src/components/status-popover-body.tsx -@@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" - import { useMutation } from "@tanstack/solid-query" - import { showToast } from "@opencode-ai/ui/toast" - import { useNavigate } from "@solidjs/router" --import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" -+import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" - import { createStore, reconcile } from "solid-js/store" - import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" - import { useLanguage } from "@/context/language" -@@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" - import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" - import { useSync } from "@/context/sync" - import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -+import { setServerSwitching } from "@/utils/server-switch" - - const pollMs = 10_000 - -@@ -292,8 +293,26 @@ export function StatusPopoverBody(props: { shown: Accessor }) { - aria-disabled={blocked()} - onClick={() => { - if (blocked()) return -- navigate("/") -- queueMicrotask(() => server.setActive(key)) -+ // Paint a full-window splash BEFORE the heavy -+ // ServerKey remount so the user gets visual -+ // feedback during the multi-second synchronous -+ // dispose cascade (xterm + file-tree + providers). -+ // setTimeout(0) yields to the browser so the -+ // splash lands on screen before the cascade -+ // starts; a second setTimeout(0) after the batch -+ // waits for the new subtree to paint, then -+ // dismisses the splash. -+ setServerSwitching(true) -+ setTimeout(() => { -+ try { -+ batch(() => { -+ navigate("/") -+ server.setActive(key) -+ }) -+ } finally { -+ setTimeout(() => setServerSwitching(false), 0) -+ } -+ }, 0) - }} - > - -@@ -329,7 +348,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) { - const run = ++dialogRun - void import("./dialog-select-server").then((x) => { - if (dialogDead || dialogRun !== run) return -- dialog.show(() => , defaultServer.refresh) -+ dialog.show( -+ () => navigate("/")} />, -+ defaultServer.refresh, -+ ) - }) - }} - > -diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx -index 57e91d6d33..edbbd752c9 100644 ---- a/packages/app/src/components/terminal.tsx -+++ b/packages/app/src/components/terminal.tsx -@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" - import { usePlatform } from "@/context/platform" - import { useSDK } from "@/context/sdk" - import { useServer } from "@/context/server" --import { monoFontFamily, useSettings } from "@/context/settings" -+import { terminalFontFamily, useSettings } from "@/context/settings" - import type { LocalPTY } from "@/context/terminal" - import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" - import { terminalWriter } from "@/utils/terminal-writer" -@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => { - }) - - createEffect(() => { -- const font = monoFontFamily(settings.appearance.font()) -+ const font = terminalFontFamily(settings.appearance.font()) - if (!term) return - setOptionIfSupported(term, "fontFamily", font) - scheduleFit() -@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => { - cols: restoreSize?.cols, - rows: restoreSize?.rows, - fontSize: 14, -- fontFamily: monoFontFamily(settings.appearance.font()), -+ fontFamily: terminalFontFamily(settings.appearance.font()), - allowTransparency: false, - convertEol: false, - theme: terminalColors(), -@@ -613,17 +613,30 @@ export const Terminal = (props: TerminalProps) => { - drop?.() - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) - -+ // Defer finalize (persistTerminal + local cleanup()) to a microtask so -+ // that its synchronous store write inside `persistTerminal` — which -+ // flows through `props.onCleanup` -> `ops.update` -> `update()` in -+ // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does -+ // NOT run inside the outer solid cleanNode cascade. Running it -+ // synchronously mid-cascade races with solid's recursive owned -+ // iteration (readSignal on a stale memo re-enters updateComputation, -+ // which nulls an ancestor's owned while the outer loop is still -+ // iterating it) and crashes with "Cannot read properties of null -+ // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode. -+ // queueMicrotask runs after the current sync reactive flush, so the -+ // store write lands in a fresh tick. - const finalize = () => { - persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) - cleanup() - } -+ const schedule = () => queueMicrotask(finalize) - - if (!output) { -- finalize() -+ schedule() - return - } - -- output.flush(finalize) -+ output.flush(schedule) - }) - - return ( -diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts -index 6788e8cc59..2d138e72f5 100644 ---- a/packages/app/src/context/global-sync/child-store.ts -+++ b/packages/app/src/context/global-sync/child-store.ts -@@ -96,8 +96,15 @@ export function createChildStoreManager(input: { - lifecycle.delete(directory) - const dispose = disposers.get(directory) - if (dispose) { -- dispose() - disposers.delete(directory) -+ // Defer the actual solid-js root disposal. When disposeDirectory runs -+ // from pinForOwner's onCleanup during a parent remount, calling -+ // dispose() here triggers a nested cleanNode cascade on the inner -+ // root while the outer cascade is mid-traversal, which corrupts -+ // solid-js's graph walk state and throws `Cannot read properties of -+ // null (reading '1')` at chunk-*.js:992. Running dispose on a -+ // microtask lets the outer cleanup finish first. -+ queueMicrotask(dispose) - } - delete children[directory] - input.onDispose(directory) -diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx -index 3bdc46391b..75e04a4a5b 100644 ---- a/packages/app/src/context/platform.tsx -+++ b/packages/app/src/context/platform.tsx -@@ -9,6 +9,111 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri - type SaveFilePickerOptions = { title?: string; defaultPath?: string } - type UpdateInfo = { updateAvailable: boolean; version?: string } - -+export type WslServerStep = "wsl" | "distro" | "opencode" -+ -+export type WslRuntimeCheck = { -+ available: boolean -+ version: string | null -+ status: string | null -+ error: string | null -+} -+export type WslInstalledDistro = { -+ name: string -+ state: string | null -+ version: number | null -+ isDefault: boolean -+} -+export type WslOnlineDistro = { -+ name: string -+ label: string -+} -+export type WslDistroProbe = { -+ name: string -+ canExecute: boolean -+ hasBash: boolean -+ hasCurl: boolean -+ username: string | null -+ isRoot: boolean | null -+ 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 WslTranscriptLine = { -+ stream: "stdout" | "stderr" | "system" -+ text: string -+ at: number -+} -+ -+export type WslServerAcknowledgements = { -+ root: boolean -+ mismatch: { path: string; version: string } | null -+} -+ -+export type WslServerConfig = { -+ id: string -+ distro: string -+ acknowledgements: WslServerAcknowledgements -+} -+ -+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 -+ opencodeChecks: Record -+ pendingRestart: boolean -+ servers: WslServerItem[] -+ job: WslJob | null -+ transcript: WslTranscriptLine[] -+ lastError: string | null -+} -+export type WslServersEvent = { type: "state"; state: WslServersState } -+ -+export type WslServersPlatform = { -+ getState(): Promise -+ subscribe(cb: (event: WslServersEvent) => void): () => void -+ probeRuntime(): Promise -+ refreshDistros(): Promise -+ installWsl(): Promise -+ installDistro(name: string): Promise -+ probeDistro(name: string): Promise -+ probeOpencode(name: string): Promise -+ installOpencode(name: string): Promise -+ openTerminal(name: string): Promise -+ addServer(distro: string): Promise -+ removeServer(id: string): Promise -+ startServer(id: string): Promise -+ stopServer(id: string): Promise -+ cancelJob(): Promise -+ updateAcknowledgements(id: string, acks: Partial): Promise -+} -+ - export type Platform = { - /** Platform discriminator */ - platform: "web" | "desktop" -@@ -64,11 +169,8 @@ export type Platform = { - /** Set the default server URL to use on app startup (platform-specific) */ - setDefaultServer?(url: ServerConnection.Key | null): Promise | void - -- /** Get the configured WSL integration (desktop only) */ -- getWslEnabled?(): Promise -- -- /** Set the configured WSL integration (desktop only) */ -- setWslEnabled?(config: boolean): Promise | void -+ /** Manage WSL sidecar servers (Electron on Windows only) */ -+ wslServers?: WslServersPlatform - - /** Get the preferred display backend (desktop only) */ - getDisplayBackend?(): Promise | DisplayBackend | null -diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx -index 9b666e5e75..0d1cee7107 100644 ---- a/packages/app/src/context/prompt.tsx -+++ b/packages/app/src/context/prompt.tsx -@@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( - const cache = new Map() - - const disposeAll = () => { -- for (const entry of cache.values()) { -- entry.dispose() -- } -+ // Defer the dispose calls to a microtask; synchronous nested dispose -+ // inside a parent onCleanup corrupts solid-js's in-flight cleanNode -+ // traversal during mass remounts (see context/terminal.tsx for the -+ // same pattern). -+ const pending = Array.from(cache.values(), (entry) => entry.dispose) - cache.clear() -+ if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) - } - - onCleanup(disposeAll) -diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx -index 1204fba557..096ef23db9 100644 ---- a/packages/app/src/context/server.tsx -+++ b/packages/app/src/context/server.tsx -@@ -23,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals - - function projectsKey(key: ServerConnection.Key) { - if (!key) return "" -- if (key === "sidecar") return "local" -+ if (key === "sidecar" || key === "local:windows") return "local" - if (isLocalHost(key)) return "local" - return key - } -@@ -81,7 +81,7 @@ export namespace ServerConnection { - return Key.make(conn.http.url) - case "sidecar": { - if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) -- return Key.make("sidecar") -+ return Key.make("local:windows") - } - case "ssh": - return Key.make(`ssh:${conn.host}`) -@@ -200,7 +200,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( - - const isReady = createMemo(() => ready() && !!state.active) - -- const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) -+ const check = (conn: ServerConnection.Any) => -+ checkServerHealth(conn.http).then((x) => { -+ if (!x.healthy) { -+ // Electron's console-message bridge only preserves the first -+ // console argument, so pre-stringify everything into one string. -+ console.warn( -+ `[server health] unhealthy key=${ServerConnection.key(conn)} url=${conn.http.url} hasAuth=${!!( -+ conn.http.username || conn.http.password -+ )}`, -+ ) -+ } -+ return x.healthy -+ }) - - createEffect(() => { - const current_ = current() -@@ -211,9 +223,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( - return - } - setState("healthy", undefined) -+ console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`) - onCleanup(startHealthPolling(current_)) - }) - -+ createEffect(() => { -+ const key = state.active -+ if (typeof window === "undefined") return -+ window.__OPENCODE__ ??= {} -+ window.__OPENCODE__.activeServer = key -+ }) -+ - const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => store.projects[origin()] ?? []) - const current: Accessor = createMemo( -@@ -221,7 +241,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( - ) - const isLocal = createMemo(() => { - const c = current() -- return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) -+ return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) - }) - - return { -diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx -index a585789ce4..1534b173eb 100644 ---- a/packages/app/src/context/settings.tsx -+++ b/packages/app/src/context/settings.tsx -@@ -53,9 +53,13 @@ export const sansDefault = "System Sans" - - const monoFallback = - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' -+const terminalMonoFallback = -+ '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono NFM", "JetBrainsMono NF", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono", "Hack Nerd Font", "MesloLGM Nerd Font Mono", "MesloLGM Nerd Font", "CaskaydiaCove NFM", "CaskaydiaCove Nerd Font Mono", "CaskaydiaMono Nerd Font Mono", ' + -+ monoFallback - const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' - - const monoBase = monoFallback -+const terminalMonoBase = terminalMonoFallback - const sansBase = sansFallback - - function input(font: string | undefined) { -@@ -85,6 +89,10 @@ export function monoFontFamily(font: string | undefined) { - return stack(font, monoBase) - } - -+export function terminalFontFamily(font: string | undefined) { -+ return stack(font, terminalMonoBase) -+} -+ - export function sansFontFamily(font: string | undefined) { - return stack(font, sansBase) - } -diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx -index 31d2d6e04c..482f55c716 100644 ---- a/packages/app/src/context/terminal.tsx -+++ b/packages/app/src/context/terminal.tsx -@@ -364,10 +364,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont - onCleanup(() => caches.delete(cache)) - - const disposeAll = () => { -- for (const entry of cache.values()) { -- entry.dispose() -- } -+ // Snapshot disposers, then defer them to a microtask. 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 -+ // solid-js's graph walk state and throwing `Cannot read properties of -+ // 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())) - } - - onCleanup(disposeAll) -diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts -index d80e9fffb0..4173cf9ca7 100644 ---- a/packages/app/src/index.ts -+++ b/packages/app/src/index.ts -@@ -1,7 +1,21 @@ - export { AppBaseProviders, AppInterface } from "./app" -+export { DialogWslServer } from "./components/dialog-wsl-server" - 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 Platform, PlatformProvider } from "./context/platform" -+export { -+ type DisplayBackend, -+ type Platform, -+ PlatformProvider, -+ type WslInstalledDistro, -+ type WslOnlineDistro, -+ type WslOpencodeCheck, -+ type WslServerConfig, -+ type WslServerItem, -+ type WslServersEvent, -+ type WslServersPlatform, -+ type WslServersState, -+ type WslServerStep, -+} from "./context/platform" - export { ServerConnection } from "./context/server" - export { handleNotificationClick } from "./utils/notification-click" -diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx -index 46cacdf627..b779ebd4f5 100644 ---- a/packages/app/src/pages/home.tsx -+++ b/packages/app/src/pages/home.tsx -@@ -75,7 +75,7 @@ export default function Home() { - size="large" - variant="ghost" - class="mt-4 mx-auto text-14-regular text-text-weak" -- onClick={() => dialog.show(() => )} -+ onClick={() => dialog.show(() => navigate("/")} />)} - > -
{ - if (dialogDead || dialogRun !== run) return -- dialog.show(() => ) -+ dialog.show(() => navigate("/")} />) - }) - } - -@@ -1840,7 +1847,7 @@ export default function Layout(props: ParentProps) { - ) - - function handleDragStart(event: unknown) { -- const id = getDraggableId(event) -+ const id = projectSortableWorktree(getDraggableId(event)) - if (!id) return - setHoverProject(undefined) - setStore("activeProject", id) -@@ -1849,11 +1856,14 @@ export default function Layout(props: ParentProps) { - function handleDragOver(event: DragEvent) { - const { draggable, droppable } = event - if (draggable && droppable) { -+ const from = projectSortableWorktree(draggable.id?.toString()) -+ const to = projectSortableWorktree(droppable.id?.toString()) -+ if (!from || !to) return - const projects = layout.projects.list() -- const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) -- const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) -+ const fromIndex = projects.findIndex((p) => p.worktree === from) -+ const toIndex = projects.findIndex((p) => p.worktree === to) - if (fromIndex !== toIndex && toIndex !== -1) { -- layout.projects.move(draggable.id.toString(), toIndex) -+ layout.projects.move(from, toIndex) - } - } - } -@@ -1891,7 +1901,7 @@ export default function Layout(props: ParentProps) { - }) - - function handleWorkspaceDragStart(event: unknown) { -- const id = getDraggableId(event) -+ const id = workspaceSortableDirectory(getDraggableId(event)) - if (!id) return - setStore("activeWorkspace", id) - } -@@ -1899,13 +1909,16 @@ export default function Layout(props: ParentProps) { - function handleWorkspaceDragOver(event: DragEvent) { - const { draggable, droppable } = event - if (!draggable || !droppable) return -+ const from = workspaceSortableDirectory(draggable.id?.toString()) -+ const to = workspaceSortableDirectory(droppable.id?.toString()) -+ if (!from || !to) return - - const project = sidebarProject() - if (!project) return - - const ids = workspaceIds(project) -- const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) -- const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) -+ const fromIndex = ids.findIndex((dir) => dir === from) -+ const toIndex = ids.findIndex((dir) => dir === to) - if (fromIndex === -1 || toIndex === -1) return - if (fromIndex === toIndex) return - -@@ -2265,7 +2278,7 @@ export default function Layout(props: ParentProps) { - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > -- -+ - - {(directory) => ( - layout.projects.list() -+ const projectIds = createMemo(() => projects().map((project) => project.worktree)) - const projectOverlay = () => store.activeProject} /> - const sidebarContent = (mobile?: boolean) => ( - layout.sidebar.opened()} - aimMove={aim.move} - projects={projects} -- renderProject={(project) => ( -- -- )} -+ projectIds={projectIds} -+ renderProject={(worktree) => { -+ const project = createMemo(() => projects().find((item) => item.worktree === worktree)) -+ return ( -+ -+ {(project) => ( -+ -+ )} -+ -+ ) -+ }} - handleDragStart={handleDragStart} - handleDragEnd={handleDragEnd} - handleDragOver={handleDragOver} -diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx -index 076e1ef88b..d681cf3218 100644 ---- a/packages/app/src/pages/layout/sidebar-project.tsx -+++ b/packages/app/src/pages/layout/sidebar-project.tsx -@@ -34,6 +34,17 @@ export type ProjectSidebarContext = { - sessionProps: Omit - } - -+const PROJECT_SORTABLE_PREFIX = "project:" -+ -+export function projectSortableId(worktree: string) { -+ return `${PROJECT_SORTABLE_PREFIX}${worktree}` -+} -+ -+export function projectSortableWorktree(id: string | undefined) { -+ if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return -+ return id.slice(PROJECT_SORTABLE_PREFIX.length) -+} -+ - export const ProjectDragOverlay = (props: { - projects: Accessor - activeProject: Accessor -@@ -275,7 +286,7 @@ export const SortableProject = (props: { - }): JSX.Element => { - const globalSync = useGlobalSync() - const language = useLanguage() -- const sortable = createSortable(props.project.worktree) -+ const sortable = createSortable(projectSortableId(props.project.worktree)) - const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) - const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) - const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) -diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx -index ca36af2a42..d9cd4d5a20 100644 ---- a/packages/app/src/pages/layout/sidebar-shell.tsx -+++ b/packages/app/src/pages/layout/sidebar-shell.tsx -@@ -11,13 +11,15 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" - import { IconButton } from "@opencode-ai/ui/icon-button" - import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" - import { type LocalProject } from "@/context/layout" -+import { projectSortableId } from "./sidebar-project" - - export const SidebarContent = (props: { - mobile?: boolean - opened: Accessor - aimMove: (event: MouseEvent) => void - projects: Accessor -- renderProject: (project: LocalProject) => JSX.Element -+ projectIds: Accessor -+ renderProject: (worktree: string) => JSX.Element - handleDragStart: (event: unknown) => void - handleDragEnd: () => void - handleDragOver: (event: DragEvent) => void -@@ -63,8 +65,8 @@ export const SidebarContent = (props: { - - -
-- p.worktree)}> -- {(project) => props.renderProject(project)} -+ -+ {(worktree) => props.renderProject(worktree)} - - void - } - -+const WORKSPACE_SORTABLE_PREFIX = "workspace:" -+ -+export function workspaceSortableId(directory: string) { -+ return `${WORKSPACE_SORTABLE_PREFIX}${directory}` -+} -+ -+export function workspaceSortableDirectory(id: string | undefined) { -+ if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return -+ return id.slice(WORKSPACE_SORTABLE_PREFIX.length) -+} -+ - export const WorkspaceDragOverlay = (props: { - sidebarProject: Accessor - activeWorkspace: Accessor -@@ -300,7 +311,7 @@ export const SortableWorkspace = (props: { - const params = useParams() - const globalSync = useGlobalSync() - const language = useLanguage() -- const sortable = createSortable(props.directory) -+ const sortable = createSortable(workspaceSortableId(props.directory)) - const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) - const [menu, setMenu] = createStore({ - open: false, -@@ -308,12 +319,20 @@ export const SortableWorkspace = (props: { - }) - const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) -- const local = createMemo(() => props.directory === props.project.worktree) -+ // Guard against `props.project` being transiently undefined during a -+ // server-switch cascade. The parent renders -+ // {(dir) => } -+ // where `project()` can flip to undefined while the enclosing -+ // gate hasn't yet unmounted this child. Bootstrap's setStore can then fire -+ // these memos with stale props. -+ const local = createMemo(() => props.directory === (props.project?.worktree ?? "")) - const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) - const workspaceValue = createMemo(() => { - const branch = workspaceStore.vcs?.branch - const name = branch ?? getFilename(props.directory) -- return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name -+ const projectId = props.project?.id -+ if (!projectId) return name -+ return props.ctx.workspaceName(props.directory, projectId, branch) ?? name - }) - const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) - const boot = createMemo(() => open() || active()) -@@ -344,7 +363,7 @@ export const SortableWorkspace = (props: { - InlineEditor={props.ctx.InlineEditor} - renameWorkspace={props.ctx.renameWorkspace} - setEditor={props.ctx.setEditor} -- projectId={props.project.id} -+ projectId={props.project?.id ?? ""} - /> - ) - -@@ -413,7 +432,7 @@ export const SortableWorkspace = (props: { - openEditor={props.ctx.openEditor} - showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} - showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} -- root={props.project.worktree} -+ root={props.project?.worktree ?? props.directory} - clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - navigateToNewSession={() => navigate(`/${slug()}/session`)} - /> -@@ -447,20 +466,33 @@ export const LocalWorkspace = (props: { - }): JSX.Element => { - const globalSync = useGlobalSync() - const language = useLanguage() -+ // Same guard pattern as SortableWorkspace: the parent passes -+ // `project={project()!}` but `project()` can transiently flip to -+ // undefined during a server-switch cascade before this component -+ // unmounts, so every reactive memo reading props.project has to -+ // tolerate undefined. -+ const worktree = createMemo(() => props.project?.worktree ?? "") - const workspace = createMemo(() => { -- const [store, setStore] = globalSync.child(props.project.worktree) -+ const dir = worktree() -+ if (!dir) return undefined -+ const [store, setStore] = globalSync.child(dir) - return { store, setStore } - }) -- const slug = createMemo(() => base64Encode(props.project.worktree)) -- const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) -- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) -+ const slug = createMemo(() => (worktree() ? base64Encode(worktree()) : "")) -+ const sessions = createMemo(() => { -+ const store = workspace()?.store -+ return store ? sortedRootSessions(store, props.sortNow()) : [] -+ }) -+ const booted = createMemo((prev) => prev || workspace()?.store.status === "complete", false) - const count = createMemo(() => sessions()?.length ?? 0) -- const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) -+ const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) })) - const loading = createMemo(() => query.isPending && count() === 0) -- const hasMore = createMemo(() => workspace().store.sessionTotal > count()) -+ const hasMore = createMemo(() => (workspace()?.store.sessionTotal ?? 0) > count()) - const loadMore = async () => { -- workspace().setStore("limit", (limit) => (limit ?? 0) + 5) -- await globalSync.project.loadSessions(props.project.worktree) -+ const dir = worktree() -+ if (!dir) return -+ workspace()?.setStore("limit", (limit) => (limit ?? 0) + 5) -+ await globalSync.project.loadSessions(dir) - } - - return ( -diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts -index 0c6189dafe..26821134c8 100644 ---- a/packages/app/src/utils/scoped-cache.test.ts -+++ b/packages/app/src/utils/scoped-cache.test.ts -@@ -24,7 +24,7 @@ describe("createScopedCache", () => { - expect(disposed).toEqual(["b"]) - }) - -- test("disposes entries on delete and clear", () => { -+ test("disposes entries on delete and clear", async () => { - const disposed: string[] = [] - const cache = createScopedCache((key) => ({ key }), { - dispose: (value) => disposed.push(value.key), -@@ -39,6 +39,9 @@ describe("createScopedCache", () => { - - cache.clear() - expect(cache.peek("b")).toBeUndefined() -+ // clear() defers dispose to a microtask to avoid nested cleanNode cascades -+ // when called from inside an onCleanup; flush the queue before asserting. -+ await Promise.resolve() - expect(disposed).toEqual(["a", "b"]) - }) - -diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts -index 224c363c1e..7044cdf03c 100644 ---- a/packages/app/src/utils/scoped-cache.ts -+++ b/packages/app/src/utils/scoped-cache.ts -@@ -89,10 +89,21 @@ export function createScopedCache(createValue: (key: string) => T, options: S - } - - const clear = () => { -- for (const [key, entry] of store) { -- dispose(key, entry) -- } -+ // Defer dispose() calls to a microtask. When clear() runs inside an -+ // onCleanup during a parent remount (e.g. context/file.tsx and -+ // context/comments.tsx both do this), synchronous dispose on cached -+ // createRoot entries starts a nested cleanNode cascade while the outer -+ // cascade is mid-traversal, corrupting solid-js's graph walk state and -+ // throwing `Cannot read properties of null (reading '1')` at -+ // chunk-*.js:992. Deferring lets the outer cleanup finish first. -+ const pending: Array<[string, Entry]> = [] -+ for (const entry of store) pending.push(entry) - store.clear() -+ if (pending.length && options.dispose) { -+ queueMicrotask(() => { -+ for (const [key, entry] of pending) dispose(key, entry) -+ }) -+ } - } - - return { -diff --git a/packages/app/src/utils/server-switch.tsx b/packages/app/src/utils/server-switch.tsx -new file mode 100644 -index 0000000000..480990b184 ---- /dev/null -+++ b/packages/app/src/utils/server-switch.tsx -@@ -0,0 +1,9 @@ -+import { createSignal } from "solid-js" -+ -+// Global flag used to paint a full-window splash overlay while a server -+// swap is in progress. ServerKey's keyed remount is a big -+// synchronous cascade (dispose + remount of the entire app subtree) that -+// can freeze the UI for several seconds; setting this true before the -+// swap and false after lets us render an overlay above the ServerKey -+// boundary so the freeze has visual feedback instead of looking stuck. -+export const [serverSwitching, setServerSwitching] = createSignal(false) -diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts -index d0e6c42b6c..267d6c6539 100644 ---- a/packages/desktop-electron/electron.vite.config.ts -+++ b/packages/desktop-electron/electron.vite.config.ts -@@ -60,6 +60,13 @@ export default defineConfig({ - plugins: [appPlugin], - publicDir: "../../../app/public", - root: "src/renderer", -+ server: { -+ host: "127.0.0.1", -+ strictPort: true, -+ hmr: { -+ host: "127.0.0.1", -+ }, -+ }, - define: { - "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), - }, -diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts -index 174da94a5d..eb0b260ea9 100644 ---- a/packages/desktop-electron/src/main/apps.ts -+++ b/packages/desktop-electron/src/main/apps.ts -@@ -1,6 +1,7 @@ - import { execFileSync } from "node:child_process" - import { existsSync, readFileSync, readdirSync } from "node:fs" - import { dirname, extname, join } from "node:path" -+import { resolveWslHome, runWslInDistro } from "./wsl" - - export function checkAppExists(appName: string): boolean { - if (process.platform === "win32") return true -@@ -13,20 +14,17 @@ export function resolveAppPath(appName: string): string | null { - return resolveWindowsAppPath(appName) - } - --export function wslPath(path: string, mode: "windows" | "linux" | null): string { -+export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { - 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 resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path -+ const output = await runWslInDistro(["wslpath", flag, resolved], distro) -+ if (output.code !== 0) { -+ throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) - } -- -- const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) -- return output.toString().trim() -+ return output.stdout.trim() - } catch (error) { - throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) - } -diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts -index 1e21661c1a..9a6bb53c64 100644 ---- a/packages/desktop-electron/src/main/constants.ts -+++ b/packages/desktop-electron/src/main/constants.ts -@@ -6,5 +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 LEGACY_LOCAL_SERVER_KEY = "localServer" - export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" -diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts -index 946e01e325..87a87e7672 100644 ---- a/packages/desktop-electron/src/main/index.ts -+++ b/packages/desktop-electron/src/main/index.ts -@@ -1,7 +1,6 @@ - import { randomUUID } from "node:crypto" - import { EventEmitter } from "node:events" - import { existsSync } from "node:fs" --import { createServer } from "node:net" - import { homedir } from "node:os" - import { join } from "node:path" - import type { Event } from "electron" -@@ -32,33 +31,54 @@ app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") - app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev")) - const { autoUpdater } = pkg - --import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" -+import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" - import { checkAppExists, resolveAppPath, wslPath } from "./apps" --import { CHANNEL, UPDATER_ENABLED } from "./constants" -+import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" - import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" - import { initLogging } from "./logging" - import { parseMarkdown } from "./markdown" - import { createMenu } from "./menu" --import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" -+import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" -+import { store } from "./store" -+import { createWslServersController } from "./wsl-servers" - import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" --import type { Server } from "virtual:opencode-server" - - const initEmitter = new EventEmitter() - let initStep: InitStep = { phase: "server_waiting" } - - let mainWindow: BrowserWindow | null = null --let server: Server.Listener | null = null -+let server: { stop(): void } | null = null - const loadingComplete = defer() - - const pendingDeepLinks: string[] = [] - - const serverReady = defer() -+void serverReady.promise.catch(() => undefined) - const logger = initLogging() -+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), -+ }, -+) - - logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - }) -+logger.log("config paths", { -+ userData: app.getPath("userData"), -+ settingsStore: store.path, -+ wslServersKey: WSL_SERVERS_KEY, -+ wslServers: store.get(WSL_SERVERS_KEY) ?? null, -+}) - - setupApp() - -@@ -66,6 +86,14 @@ function setupApp() { - ensureLoopbackNoProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - -+ process.on("uncaughtException", (error) => { -+ logger.error("main process uncaught exception", error) -+ }) -+ -+ process.on("unhandledRejection", (reason) => { -+ logger.error("main process unhandled rejection", reason) -+ }) -+ - if (!app.requestSingleInstanceLock()) { - app.quit() - return -@@ -88,15 +116,18 @@ function setupApp() { - - app.on("before-quit", () => { - killSidecar() -+ wslServers.stopAll() - }) - - app.on("will-quit", () => { - killSidecar() -+ wslServers.stopAll() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - killSidecar() -+ wslServers.stopAll() - app.exit(0) - }) - } -@@ -132,19 +163,38 @@ async function initialize() { - const sqliteDone = needsMigration ? defer() : undefined - let overlay: BrowserWindow | null = null - -- const port = await getSidecarPort() -+ const port = await allocatePort() - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() -+ const key = "local:windows" - -- logger.log("spawning sidecar", { url }) -- const { listener, health } = await spawnLocalServer(hostname, port, password) -- server = listener -- serverReady.resolve({ -+ logger.log("spawning windows sidecar", { url }) -+ const startupData: ServerReadyData = { - url, - username: "opencode", - password, -- }) -+ local: { -+ key, -+ url, -+ username: "opencode", -+ password, -+ }, -+ } -+ let startupError: Error | null = null -+ const startup = await (async () => { -+ try { -+ return await spawnLocalServer(hostname, port, password) -+ } catch (error) { -+ startupError = asError(error) -+ logger.error("windows sidecar startup failed", startupError) -+ return undefined -+ } -+ })() -+ server = startup?.listener ?? null -+ -+ // Initialize WSL sidecars in parallel; failures do not block app startup. -+ void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) - - const loadingTask = (async () => { - logger.log("sidecar connection started", { url }) -@@ -160,14 +210,24 @@ async function initialize() { - await sqliteDone?.promise - } - -- await Promise.race([ -- health.wait, -- delay(30_000).then(() => { -- throw new Error("Sidecar health check timed out") -- }), -- ]).catch((error) => { -- logger.error("sidecar health check failed", error) -- }) -+ if (startup) { -+ await Promise.race([ -+ startup.health.wait, -+ delay(30_000).then(() => { -+ throw new Error("Sidecar health check timed out") -+ }), -+ ]) -+ .then(() => { -+ serverReady.resolve(startupData) -+ }) -+ .catch((error) => { -+ startupError = asError(error) -+ logger.error("sidecar health check failed", startupError) -+ serverReady.reject(startupError) -+ }) -+ } else { -+ serverReady.reject(startupError ?? new Error("Local server startup failed")) -+ } - - logger.log("loading task finished") - })() -@@ -181,6 +241,7 @@ async function initialize() { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow(globals) -+ wireWindowDiagnostics(overlay, "loading") - await delay(1_000) - } - } -@@ -193,11 +254,67 @@ async function initialize() { - } - - mainWindow = createMainWindow(globals) -+ wireWindowDiagnostics(mainWindow, "main") - wireMenu() - - overlay?.close() - } - -+function wireWindowDiagnostics(win: BrowserWindow, label: string) { -+ win.webContents.on("console-message", (_event, level, message, line, sourceId) => { -+ // Render `message` as a block so multi-line stack traces survive; the -+ // previous shape stuffed the message into a JSON object which escaped -+ // `\n` and made stacks unreadable. -+ const location = sourceId ? ` [${sourceId}:${line}]` : "" -+ const text = `${label} renderer${location}\n${message}` -+ if (level >= 3) { -+ logger.error(text) -+ return -+ } -+ if (level >= 2) { -+ logger.warn(text) -+ return -+ } -+ logger.log(text) -+ }) -+ -+ win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { -+ logger.error(`${label} renderer failed load`, { -+ errorCode, -+ errorDescription, -+ validatedURL, -+ isMainFrame, -+ }) -+ }) -+ -+ win.webContents.on("render-process-gone", (_event, details) => { -+ logger.error(`${label} renderer process gone`, details) -+ }) -+ -+ win.webContents.on("preload-error", (_event, path, error) => { -+ logger.error(`${label} preload error`, { -+ path, -+ error: error instanceof Error ? (error.stack ?? error.message) : String(error), -+ }) -+ }) -+ -+ // DevTools accelerators on Windows/Linux where the menu isn't created. -+ win.webContents.on("before-input-event", (_event, input) => { -+ if (input.type !== "keyDown") return -+ const key = input.key -+ const toggle = -+ key === "F12" || -+ (input.control && input.shift && (key === "I" || key === "i")) || -+ (input.meta && input.alt && (key === "I" || key === "i")) -+ if (!toggle) return -+ win.webContents.toggleDevTools() -+ }) -+ -+ win.on("unresponsive", () => { -+ logger.error(`${label} window became unresponsive`) -+ }) -+} -+ - function wireMenu() { - if (!mainWindow) return - createMenu({ -@@ -206,16 +323,13 @@ function wireMenu() { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), -- relaunch: () => { -- killSidecar() -- app.relaunch() -- app.exit(0) -- }, -+ relaunch: () => relaunchApp(), - }) - } - - registerIpcHandlers({ - killSidecar: () => killSidecar(), -+ relaunch: () => relaunchApp(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) -@@ -229,15 +343,29 @@ registerIpcHandlers({ - initEmitter.off("step", listener) - } - }, -+ getWslServersState: () => wslServers.getState(), -+ onWslServersEvent: (listener) => wslServers.subscribe(listener), -+ wslServersProbeRuntime: () => wslServers.probeRuntime(), -+ wslServersRefreshDistros: () => wslServers.refreshDistros(), -+ wslServersInstallWsl: () => wslServers.installWsl(), -+ wslServersInstallDistro: (name) => wslServers.installDistro(name), -+ wslServersProbeDistro: (name) => wslServers.probeDistro(name), -+ wslServersProbeOpencode: (name) => wslServers.probeOpencode(name), -+ wslServersInstallOpencode: (name) => wslServers.installOpencode(name), -+ wslServersOpenTerminal: (name) => wslServers.openTerminal(name), -+ wslServersAddServer: (distro) => wslServers.addServer(distro), -+ wslServersRemoveServer: (id) => wslServers.removeServer(id), -+ wslServersStartServer: (id) => wslServers.startServer(id), -+ wslServersStopServer: (id) => wslServers.stopServer(id), -+ wslServersCancelJob: () => wslServers.cancelJob(), -+ wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks), - 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: async (appName) => checkAppExists(appName), -- wslPath: async (path, mode) => wslPath(path, mode), -+ wslPath: async (path, mode, distro) => wslPath(path, mode, distro), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), -@@ -252,6 +380,15 @@ function killSidecar() { - server = null - } - -+function relaunchApp() { -+ // app.exit() skips before-quit / will-quit, so relaunch callers must -+ // explicitly stop sidecars here rather than relying on process hooks. -+ killSidecar() -+ wslServers.stopAll() -+ app.relaunch() -+ app.exit(0) -+} -+ - function ensureLoopbackNoProxy() { - const loopback = ["127.0.0.1", "localhost", "::1"] - const upsert = (key: string) => { -@@ -272,29 +409,6 @@ function ensureLoopbackNoProxy() { - upsert("no_proxy") - } - --async function getSidecarPort() { -- const fromEnv = process.env.OPENCODE_PORT -- if (fromEnv) { -- const parsed = Number.parseInt(fromEnv, 10) -- if (!Number.isNaN(parsed)) return parsed -- } -- -- return await new Promise((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 -- } -- const port = address.port -- server.close(() => resolve(port)) -- }) -- }) --} -- - function sqliteFileExists() { - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") -@@ -358,6 +472,7 @@ async function checkUpdate() { - async function installUpdate() { - if (!updateReady) return - killSidecar() -+ wslServers.stopAll() - autoUpdater.quitAndInstall() - } - -@@ -408,6 +523,10 @@ function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) - } - -+function asError(error: unknown) { -+ return error instanceof Error ? error : new Error(String(error)) -+} -+ - function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void -diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts -index 52d87ed7ee..c6d2c4face 100644 ---- a/packages/desktop-electron/src/main/ipc.ts -+++ b/packages/desktop-electron/src/main/ipc.ts -@@ -2,7 +2,16 @@ import { execFile } from "node:child_process" - import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" - import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" - --import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" -+import type { -+ InitStep, -+ ServerReadyData, -+ SqliteMigrationProgress, -+ TitlebarTheme, -+ WslServerAcknowledgements, -+ WslServerConfig, -+ WslServersEvent, -+ WslServersState, -+} from "../preload/types" - import { getStore } from "./store" - import { setTitlebar } from "./windows" - -@@ -13,16 +22,31 @@ const pickerFilters = (ext?: string[]) => { - - type Deps = { - killSidecar: () => void -+ relaunch: () => void - awaitInitialization: (sendStep: (step: InitStep) => void) => Promise -+ getWslServersState: () => Promise | WslServersState -+ onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void -+ wslServersProbeRuntime: () => Promise | void -+ wslServersRefreshDistros: () => Promise | void -+ wslServersInstallWsl: () => Promise | void -+ wslServersInstallDistro: (name: string) => Promise | void -+ wslServersProbeDistro: (name: string) => Promise | void -+ wslServersProbeOpencode: (name: string) => Promise | void -+ wslServersInstallOpencode: (name: string) => Promise | void -+ wslServersOpenTerminal: (name: string) => Promise | void -+ wslServersAddServer: (distro: string) => Promise | WslServerConfig -+ wslServersRemoveServer: (id: string) => Promise | void -+ wslServersStartServer: (id: string) => Promise | void -+ wslServersStopServer: (id: string) => Promise | void -+ wslServersCancelJob: () => Promise | void -+ wslServersUpdateAcknowledgements: (id: string, acks: Partial) => Promise | void - getDefaultServerUrl: () => Promise | string | null - setDefaultServerUrl: (url: string | null) => Promise | void -- getWslConfig: () => Promise -- setWslConfig: (config: WslConfig) => Promise | void - getDisplayBackend: () => Promise - setDisplayBackend: (backend: string | null) => Promise | void - parseMarkdown: (markdown: string) => Promise | string - checkAppExists: (appName: string) => Promise | boolean -- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise -+ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise - resolveAppPath: (appName: string) => Promise - loadingWindowComplete: () => void - runUpdater: (alertOnFail: boolean) => Promise | void -@@ -32,25 +56,62 @@ type Deps = { - } - - export function registerIpcHandlers(deps: Deps) { -+ const offWslServers = deps.onWslServersEvent((payload) => { -+ for (const win of BrowserWindow.getAllWindows()) { -+ if (win.isDestroyed()) continue -+ win.webContents.send("wsl-servers-event", payload) -+ } -+ }) -+ app.once("will-quit", offWslServers) -+ - ipcMain.handle("kill-sidecar", () => deps.killSidecar()) - ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { - const send = (step: InitStep) => event.sender.send("init-step", step) - return deps.awaitInitialization(send) - }) -+ ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) -+ ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) -+ ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) -+ ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) -+ ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => -+ deps.wslServersInstallDistro(name), -+ ) -+ ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => -+ deps.wslServersProbeDistro(name), -+ ) -+ ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => -+ deps.wslServersProbeOpencode(name), -+ ) -+ ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => -+ deps.wslServersInstallOpencode(name), -+ ) -+ ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => -+ deps.wslServersOpenTerminal(name), -+ ) -+ ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro)) -+ ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id)) -+ ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id)) -+ ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id)) -+ ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) -+ ipcMain.handle( -+ "wsl-servers-update-acknowledgements", -+ (_event: IpcMainInvokeEvent, id: string, acks: Partial) => -+ deps.wslServersUpdateAcknowledgements(id, acks), -+ ) - ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) - 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( -+ "wsl-path", -+ (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) => -+ deps.wslPath(path, mode, distro), - ) - ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) -@@ -167,8 +228,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()) -diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts -index fcf209fb67..f55554a8eb 100644 ---- a/packages/desktop-electron/src/main/menu.ts -+++ b/packages/desktop-electron/src/main/menu.ts -@@ -75,9 +75,9 @@ export function createMenu(deps: Deps) { - { role: "reload" }, - { role: "toggleDevTools" }, - { type: "separator" }, -- { role: "resetZoom" }, -- { role: "zoomIn" }, -- { role: "zoomOut" }, -+ { label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") }, -+ { label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") }, -+ { label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], -diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts -index 5a6050013a..ffb1d2e262 100644 ---- a/packages/desktop-electron/src/main/server.ts -+++ b/packages/desktop-electron/src/main/server.ts -@@ -1,9 +1,11 @@ -+import { spawn } from "node:child_process" -+import { randomUUID } from "node:crypto" -+import { createServer } from "node:net" - import { app } 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 { store } from "./store" -- --export type WslConfig = { enabled: boolean } -+import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" - - export type HealthCheck = { wait: Promise } - -@@ -21,13 +23,26 @@ export function setDefaultServerUrl(url: string | null) { - store.delete(DEFAULT_SERVER_URL_KEY) - } - --export function getWslConfig(): WslConfig { -- const value = store.get(WSL_ENABLED_KEY) -- return { enabled: typeof value === "boolean" ? value : false } --} -- --export function setWslConfig(config: WslConfig) { -- store.set(WSL_ENABLED_KEY, config.enabled) -+export async function allocatePort() { -+ const fromEnv = process.env.OPENCODE_PORT -+ if (fromEnv) { -+ const parsed = Number.parseInt(fromEnv, 10) -+ if (!Number.isNaN(parsed)) return parsed -+ } -+ return new Promise((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 -+ } -+ const port = address.port -+ server.close(() => resolve(port)) -+ }) -+ }) - } - - export async function spawnLocalServer(hostname: string, port: number, password: string) { -@@ -57,6 +72,107 @@ export async function spawnLocalServer(hostname: string, port: number, password: - return { listener, health: { wait } } - } - -+export type WslSidecar = { -+ listener: { stop: () => void } -+ url: string -+ username: string | null -+ password: string -+} -+ -+export async function spawnWslSidecar( -+ distro: string, -+ opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, -+): Promise { -+ // Every wsl.exe invocation below goes through wslArgs which injects -+ // `--user root`. That matters even when a distro has DefaultUid=0 -+ // (i.e. the interactive first-run user account setup never ran): -+ // explicit --user root bypasses the OOBE hook that would otherwise -+ // prompt on stdin, so we can resolve opencode and spawn the sidecar -+ // without any machine-wide first-run handshake. The earlier Ubuntu -+ // hang was caused by invoking without --user (default uid 0 triggers -+ // OOBE), not by the registry state itself. We still have a 20s -+ // timeout in runCommand as a safety net for true wsl.exe wedges. -+ 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", -+ "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", -+ "export OPENCODE_EXPERIMENTAL_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}`, -+ ].join("\n") -+ -+ const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { -+ stdio: ["pipe", "pipe", "pipe"], -+ windowsHide: true, -+ }) -+ child.stdin.end(script) -+ -+ let settled = false -+ const recentOutput: string[] = [] -+ const emit = (line: WslCommandLine) => { -+ if (settled || !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((_, 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 healthPromise = (async () => { -+ while (true) { -+ await new Promise((resolve) => setTimeout(resolve, 100)) -+ if (await checkHealth(url, password)) return -+ } -+ })() -+ -+ const timeoutMs = opts.healthTimeoutMs ?? 30_000 -+ const timeout = new Promise((_, reject) => { -+ const id = setTimeout( -+ () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)), -+ timeoutMs, -+ ) -+ void healthPromise.finally(() => clearTimeout(id)) -+ }) -+ -+ try { -+ await Promise.race([healthPromise, exit, timeout]) -+ } catch (error) { -+ child.kill() -+ throw error -+ } finally { -+ settled = true -+ } -+ -+ return { -+ listener: { -+ stop() { -+ child.kill() -+ }, -+ }, -+ url, -+ username, -+ password, -+ } -+} -+ - function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} -@@ -73,6 +189,33 @@ function prepareServerEnv(password: string) { - Object.assign(process.env, env) - } - -+function shellEscape(value: string) { -+ return `'${value.replace(/'/g, `'"'"'`)}'` -+} -+ -+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() ?? "" -+ for (const line of lines) onLine({ stream: source, text: line }) -+ }) -+ 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}` -+} -+ - export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrl: URL - try { -diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts -index 95f80c1240..26f138f5fb 100644 ---- a/packages/desktop-electron/src/main/windows.ts -+++ b/packages/desktop-electron/src/main/windows.ts -@@ -134,7 +134,9 @@ export function createLoadingWindow(globals: Globals) { - function loadWindow(win: BrowserWindow, html: string) { - const devUrl = process.env.ELECTRON_RENDERER_URL - if (devUrl) { -- const url = new URL(html, devUrl) -+ const base = new URL(devUrl) -+ if (base.hostname === "localhost") base.hostname = "127.0.0.1" -+ const url = new URL(html, base) - void win.loadURL(url.toString()) - return - } -@@ -157,7 +159,9 @@ function injectGlobals(win: BrowserWindow, globals: Globals) { - - function wireZoom(win: BrowserWindow) { - win.webContents.setZoomFactor(1) -- win.webContents.on("zoom-changed", () => { -- win.webContents.setZoomFactor(1) -- }) -+ // Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled -+ // in the renderer so the Solid `webviewZoom` signal stays the single source -+ // of truth; a stray `zoom-changed` handler here would race with the renderer -+ // and intermittently snap the factor back to 1. -+ void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined) - } -diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts -new file mode 100644 -index 0000000000..c35e4e52bf ---- /dev/null -+++ b/packages/desktop-electron/src/main/wsl-servers.ts -@@ -0,0 +1,522 @@ -+import type { -+ WslDistroProbe, -+ WslInstalledDistro, -+ WslJob, -+ WslOnlineDistro, -+ WslOpencodeCheck, -+ WslRuntimeCheck, -+ WslServerAcknowledgements, -+ WslServerConfig, -+ WslServerItem, -+ WslServerRuntime, -+ WslServersEvent, -+ WslServersState, -+ WslTranscriptLine, -+} from "../preload/types" -+import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" -+import { spawnWslSidecar } from "./server" -+import { store } from "./store" -+import type { WslCommandLine } from "./wsl" -+import { -+ installWslDistro, -+ installWslOpencode, -+ installWslRuntimeElevated, -+ listInstalledWslDistros, -+ listOnlineWslDistros, -+ openWslTerminal, -+ probeWslDistro, -+ probeWslRuntime, -+ readWslCommandVersion, -+ resolveWslOpencode, -+ upgradeWslOpencode, -+ wslNeedsRestart, -+} from "./wsl" -+ -+type RunningSidecar = { -+ listener: { stop: () => void } -+ url: string -+ username: string | null -+ password: string -+} -+ -+type SpawnSidecar = (distro: string) => Promise -+ -+type ControllerLogger = { -+ log: (message: string, meta?: unknown) => void -+ error: (message: string, meta?: unknown) => void -+} -+ -+export type WslServersController = ReturnType -+ -+export function wslServerIdForDistro(distro: string) { -+ return `wsl:${distro}` -+} -+ -+export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { -+ const mainLogger: ControllerLogger | undefined = logger -+ let state: WslServersState = initialState() -+ const listeners = new Set<(event: WslServersEvent) => void>() -+ const sidecars = new Map() -+ const startAttempts = new Map() -+ let jobAbort: AbortController | undefined -+ -+ const emit = () => { -+ for (const listener of listeners) listener({ type: "state", state }) -+ } -+ -+ const setState = (next: Partial) => { -+ state = { ...state, ...next } -+ emit() -+ } -+ -+ const appendTranscript = (line: Omit) => { -+ setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] }) -+ } -+ -+ const clearTranscript = () => setState({ transcript: [] }) -+ -+ const persistServers = (servers: WslServerConfig[]) => { -+ store.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, opts: { keepTranscript?: boolean } = {}): AbortController => { -+ jobAbort?.abort() -+ const abort = new AbortController() -+ jobAbort = abort -+ if (!opts.keepTranscript) clearTranscript() -+ setState({ job, lastError: null }) -+ return abort -+ } -+ -+ const endJob = (abort: AbortController, error?: Error | null) => { -+ if (jobAbort !== abort) return -+ jobAbort = undefined -+ setState({ job: null, lastError: error?.message ?? null }) -+ } -+ -+ const onLine = (line: WslCommandLine) => appendTranscript(line) -+ -+ 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 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" }) -+ mainLogger?.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, -+ }) -+ 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 -+ 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. -+ mainLogger?.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 -+ try { -+ existing.listener.stop() -+ } catch { -+ // ignore stop errors -+ } -+ sidecars.delete(id) -+ } -+ -+ const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { -+ 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, err) -+ throw err -+ } -+ } -+ -+ return { -+ getState() { -+ return state -+ }, -+ subscribe(listener: (event: WslServersEvent) => void) { -+ listeners.add(listener) -+ return () => listeners.delete(listener) -+ }, -+ -+ async initialize() { -+ refreshFromStore() -+ await Promise.all(state.servers.map((item) => startServer(item.config.id))) -+ }, -+ -+ async probeRuntime() { -+ await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: "Checking WSL runtime" }) -+ const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) -+ setState({ -+ runtime, -+ pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, -+ }) -+ }) -+ }, -+ -+ async refreshDistros() { -+ await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: "Listing WSL distros" }) -+ const [installedResult, onlineResult] = await Promise.allSettled([ -+ listInstalledWslDistros({ signal: abort.signal, onLine }), -+ listOnlineWslDistros({ signal: abort.signal, onLine }), -+ ]) -+ const installed = installedResult.status === "fulfilled" ? installedResult.value : [] -+ const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] -+ setState({ installed, online }) -+ }) -+ }, -+ -+ async installWsl() { -+ await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: "Installing WSL runtime" }) -+ const result = await installWslRuntimeElevated({ signal: abort.signal, onLine }) -+ if (result.code !== 0) { -+ const message = summarize(result.stderr || result.stdout) || "WSL installation failed" -+ throw new Error(message) -+ } -+ const pendingRestart = wslNeedsRestart(result) -+ setState({ pendingRestart }) -+ if (!pendingRestart) { -+ const runtime = await probeWslRuntime({ signal: abort.signal, onLine }) -+ setState({ runtime }) -+ } -+ }) -+ }, -+ -+ async installDistro(name: string) { -+ await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` }) -+ const result = await installWslDistro(name, { signal: abort.signal, onLine }) -+ if (result.code !== 0) { -+ const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` -+ throw new Error(message) -+ } -+ const [installedResult, onlineResult] = await Promise.allSettled([ -+ listInstalledWslDistros({ signal: abort.signal, onLine }), -+ listOnlineWslDistros({ signal: abort.signal, onLine }), -+ ]) -+ const installed = installedResult.status === "fulfilled" ? installedResult.value : [] -+ const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] -+ const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) -+ setState({ -+ installed, -+ online, -+ distroProbes: { ...state.distroProbes, [name]: probe }, -+ }) -+ }) -+ }, -+ -+ async probeDistro(name: string) { -+ await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: `Checking ${name}` }) -+ const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) -+ setState({ distroProbes: { ...state.distroProbes, [name]: probe } }) -+ }) -+ }, -+ -+ 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), -+ }, -+ }) -+ }) -+ }, -+ -+ async installOpencode(name: string) { -+ await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { -+ appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` }) -+ const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine }) -+ const existingVersion = resolved -+ ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) -+ : null -+ const result = -+ resolved && existingVersion -+ ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine }) -+ : await installWslOpencode(appVersion, name, { signal: abort.signal, onLine }) -+ 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), -+ }, -+ }) -+ }) -+ }, -+ -+ async openTerminal(name: string) { -+ await openWslTerminal(name) -+ }, -+ -+ async cancelJob() { -+ jobAbort?.abort() -+ jobAbort = undefined -+ appendTranscript({ stream: "system", text: "Canceled" }) -+ setState({ job: null }) -+ }, -+ -+ async addServer(distro: string): Promise { -+ 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, -+ acknowledgements: { root: false, mismatch: null }, -+ } -+ persistServers([...readPersistedServers(), config]) -+ setState({ -+ servers: [...state.servers, { config, runtime: { kind: "starting" } }], -+ }) -+ void startServer(id) -+ return config -+ }, -+ -+ async removeServer(id: string) { -+ 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) }) -+ }, -+ -+ startServer, -+ -+ async stopServer(id: string) { -+ invalidateStartAttempt(id) -+ await stopServerInternal(id) -+ setRuntime(id, { kind: "stopped" }) -+ }, -+ -+ async updateAcknowledgements(id: string, acks: Partial) { -+ const persisted = readPersistedServers() -+ const next = persisted.map((config) => -+ config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config, -+ ) -+ persistServers(next) -+ refreshFromStore() -+ }, -+ -+ stopAll() { -+ for (const item of state.servers) invalidateStartAttempt(item.config.id) -+ for (const [id] of sidecars) { -+ const existing = sidecars.get(id) -+ try { -+ existing?.listener.stop() -+ } catch { -+ // ignore -+ } -+ } -+ sidecars.clear() -+ }, -+ } -+} -+ -+function initialState(): WslServersState { -+ return { -+ runtime: null, -+ installed: [], -+ online: [], -+ distroProbes: {}, -+ opencodeChecks: {}, -+ pendingRestart: false, -+ servers: [], -+ job: null, -+ transcript: [], -+ lastError: null, -+ } -+} -+ -+function readPersistedServers(): WslServerConfig[] { -+ 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) -+ } -+ const migrated = migrateLegacyLocalServer() -+ if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) -+ return migrated -+} -+ -+function migrateLegacyLocalServer(): WslServerConfig[] { -+ const legacy = store.get(LEGACY_LOCAL_SERVER_KEY) -+ if (!legacy || typeof legacy !== "object") return [] -+ const record = legacy as Record -+ if (record.mode !== "wsl") return [] -+ const distro = typeof record.distro === "string" ? record.distro : null -+ if (!distro) return [] -+ return [ -+ { -+ id: wslServerIdForDistro(distro), -+ distro, -+ acknowledgements: { root: false, mismatch: null }, -+ }, -+ ] -+} -+ -+function normalizePersistedServer(value: unknown): WslServerConfig[] { -+ if (!value || typeof value !== "object") return [] -+ const record = value as Record -+ 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, -+ acknowledgements: normalizeAcks(record.acknowledgements), -+ }, -+ ] -+} -+ -+function normalizeAcks(value: unknown): WslServerAcknowledgements { -+ const record = value && typeof value === "object" ? (value as Record) : {} -+ const mismatch = -+ record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record) : null -+ return { -+ root: record.root === true, -+ mismatch: -+ mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string" -+ ? { path: mismatch.path, version: mismatch.version } -+ : null, -+ } -+} -+ -+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 summarize(value: string) { -+ return value -+ .split(/\r?\n/g) -+ .map((line) => line.trim()) -+ .filter(Boolean) -+ .join("\n") -+} -+ -+// Re-export types used by callers -+export type { -+ WslInstalledDistro, -+ WslOnlineDistro, -+ WslRuntimeCheck, -+ WslDistroProbe, -+ WslOpencodeCheck, -+ WslServerConfig, -+ WslServerItem, -+ WslServerRuntime, -+ WslServersEvent, -+ WslServersState, -+} -diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts -new file mode 100644 -index 0000000000..07a22b8252 ---- /dev/null -+++ b/packages/desktop-electron/src/main/wsl.ts -@@ -0,0 +1,491 @@ -+import { spawn } from "node:child_process" -+import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" -+ -+export type WslCommandLine = { -+ stream: "stdout" | "stderr" -+ text: string -+} -+ -+export type WslCommandResult = { -+ code: number | null -+ signal: NodeJS.Signals | null -+ stdout: string -+ stderr: string -+} -+ -+type RunWslOptions = { -+ onLine?: (line: WslCommandLine) => void -+ 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 -+ -+// `--user root` bypasses the distro's default-user requirement. A freshly -+// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively -+// for a username/password on its first invocation; when spawned with -+// piped stdio that prompt blocks forever or silently reads garbage, -+// leaving the sidecar hanging and the server unhealthy. Running as root -+// sidesteps the entire first-run setup flow — opencode only needs an -+// HTTP listener in the distro, not a per-user environment, so root is -+// a safe default for the sidecar process. -+export function wslArgs(args: string[], distro?: string | null) { -+ if (distro) return ["-d", distro, "--user", "root", "--", ...args] -+ return ["--user", "root", "--", ...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((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 = "" -+ let stdoutPending = "" -+ let stderrPending = "" -+ const stdoutDecoder = createOutputDecoder() -+ const stderrDecoder = createOutputDecoder() -+ -+ const flush = (stream: WslCommandLine["stream"], pending: string) => { -+ if (!pending) return "" -+ opts.onLine?.({ stream, text: pending }) -+ return "" -+ } -+ -+ 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 }) -+ 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 }) -+ } -+ -+ child.stdout.on("data", (chunk: Buffer) => { -+ append("stdout", stdoutDecoder.decode(chunk)) -+ }) -+ child.stdout.on("end", () => { -+ append("stdout", stdoutDecoder.flush()) -+ stdoutPending = flush("stdout", stdoutPending) -+ }) -+ -+ child.stderr.on("data", (chunk: Buffer) => { -+ append("stderr", stderrDecoder.decode(chunk)) -+ }) -+ child.stderr.on("end", () => { -+ append("stderr", stderrDecoder.flush()) -+ stderrPending = flush("stderr", stderrPending) -+ }) -+ -+ child.once("error", (error) => { -+ clearTimeout(timeoutId) -+ reject(error) -+ }) -+ child.once("close", (code, signal) => { -+ clearTimeout(timeoutId) -+ resolve({ code, signal, 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 type WslRegistryDistro = { -+ name: string -+ defaultUid: number -+ state: number -+ version: number -+} -+ -+// Distros that are designed to run as root and don't have a user-level -+// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that -+// prompts for a UNIX username on first invocation; if that never runs, -+// wsl.exe -d hangs silently forever. -+const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) -+ -+// Read LXSS metadata from the Windows registry. This never invokes -+// wsl.exe, so it is safe to call when wsl.exe itself is wedged. -+// DefaultUid === 0 on a user-oriented distro means the first-run -+// "Create a default UNIX user account" step never completed. -+// -+// Uses a `reg query` fallback strategy because some hosts (e.g. Electron -+// spawning PowerShell with certain user profiles) return nothing from the -+// PowerShell registry provider; parsing `reg query` output is ugly but -+// native Windows and always available. -+export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise { -+ // `reg query` prints each subkey's values in a stable format: -+ // -+ // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{guid} -+ // DistributionName REG_SZ Ubuntu-24.04 -+ // DefaultUid REG_DWORD 0x0 -+ // State REG_DWORD 0x1 -+ // Version REG_DWORD 0x2 -+ // ... -+ const result = await runCommand( -+ "reg.exe", -+ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss", "/s"], -+ opts, -+ ) -+ const stdout = result.stdout -+ if (result.code !== 0 || !stdout) { -+ ;(opts?.onLine ?? (() => undefined))({ -+ stream: "stderr", -+ text: `reg query failed code=${result.code} stderr=${result.stderr.slice(0, 200)}`, -+ }) -+ return [] -+ } -+ const blocks = stdout.split(/\r?\n\r?\n/) -+ const out: WslRegistryDistro[] = [] -+ for (const block of blocks) { -+ const header = block.match(/^(HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\\{[^}]+\})/i) -+ if (!header) continue -+ const name = block.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/m)?.[1] -+ if (!name) continue -+ const uidHex = block.match(/^\s+DefaultUid\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" -+ const stateHex = block.match(/^\s+State\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" -+ const versionHex = block.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" -+ out.push({ -+ name, -+ defaultUid: Number.parseInt(uidHex, 16), -+ state: Number.parseInt(stateHex, 16), -+ version: Number.parseInt(versionHex, 16), -+ }) -+ } -+ return out -+} -+ -+export type WslFirstRunCheck = -+ | { status: "ok" } -+ | { status: "needs-first-run"; defaultUid: number } -+ | { status: "not-installed" } -+ -+export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise { -+ const distros = await readWslDistrosFromRegistry(opts) -+ const entry = distros.find((d) => d.name === distro) -+ if (!entry) return { status: "not-installed" } -+ if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" } -+ if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid } -+ return { status: "ok" } -+} -+ -+export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { -+ return runWslInDistro(["sh", "-lc", script], distro, opts) -+} -+ -+export function runWslBash(script: string, distro?: string | null, opts?: RunWslOptions) { -+ return runWslInDistro(["bash", "-lc", script], distro, opts) -+} -+ -+export async function probeWslRuntime(opts?: RunWslOptions): Promise { -+ 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, -+ status: null, -+ error: summarize(version.stderr || version.stdout) || "WSL is unavailable", -+ } -+ } -+ -+ const status = await runWsl(["--status"], opts).catch(() => undefined) -+ return { -+ available: true, -+ version: firstLine(version.stdout), -+ status: status?.code === 0 ? summarize(status.stdout) : null, -+ 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 installWslRuntime(opts?: RunWslOptions) { -+ return runWsl(["--install", "--no-distribution"], opts) -+} -+ -+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, opts) -+} -+ -+export async function installWslDistro(name: string, opts?: RunWslOptions) { -+ return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) -+} -+ -+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, -+ ) -+} -+ -+export function wslNeedsRestart(result: WslCommandResult) { -+ return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) -+} -+ -+export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { -+ 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, -+ username: null, -+ isRoot: null, -+ error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", -+ } -+ } -+ -+ const [bash, curl, user] = 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), -+ runWslSh("id -un 2>/dev/null || true", name, opts), -+ ]) -+ -+ const username = summarize(user.stdout) -+ return { -+ name, -+ canExecute: true, -+ hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", -+ hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", -+ username: username || null, -+ isRoot: username ? username === "root" : null, -+ error: null, -+ } -+} -+ -+async function readWslDefaultUser(distro: string, opts?: RunWslOptions) { -+ const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro) -+ if (!entry || entry.defaultUid === 0) return null -+ -+ const passwd = firstLine( -+ ( -+ await runWslSh( -+ [ -+ "if command -v getent >/dev/null 2>&1; then", -+ ` getent passwd ${entry.defaultUid}`, -+ "else", -+ ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`, -+ "fi", -+ ].join("\n"), -+ distro, -+ opts, -+ ) -+ ).stdout, -+ ) -+ if (!passwd) return null -+ -+ const parts = passwd.split(":") -+ const username = parts[0]?.trim() ?? "" -+ const home = parts[5]?.trim() ?? "" -+ if (!home) return null -+ return { username: username || null, home } -+} -+ -+export async function resolveWslHome(distro: string, opts?: RunWslOptions) { -+ return (await readWslDefaultUser(distro, opts))?.home ?? "/root" -+} -+ -+function opencodeCandidate(path: string) { -+ return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi` -+} -+ -+export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { -+ const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) -+ if (command && !command.startsWith("/mnt/")) return command -+ -+ const home = await resolveWslHome(distro, opts) -+ for (const candidate of [ -+ ...(home !== "/root" -+ ? [ -+ opencodeCandidate(`${home}/.local/bin/opencode`), -+ opencodeCandidate(`${home}/bin/opencode`), -+ opencodeCandidate(`${home}/.opencode/bin/opencode`), -+ ] -+ : []), -+ 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', -+ 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', -+ 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', -+ 'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi', -+ ]) { -+ const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout) -+ if (resolved) return resolved -+ } -+ -+ return null -+} -+ -+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 async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) { -+ return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts) -+} -+ -+export function openWslTerminal(distro?: string | null) { -+ return new Promise((resolve, reject) => { -+ const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", 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, state, version] = match -+ if (!name || /^name$/i.test(name)) return [] -+ return [ -+ { -+ name: name.trim(), -+ state: state || null, -+ 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 -+ ) -+} -+ -+function summarize(value: string) { -+ return value -+ .split(/\r?\n/g) -+ .map((line) => line.trim()) -+ .filter(Boolean) -+ .join("\n") -+} -+ -+function shellEscape(value: string) { -+ return `'${value.replace(/'/g, `'"'"'`)}'` -+} -diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts -index 296fcb2f1c..faf0d692cb 100644 ---- a/packages/desktop-electron/src/preload/index.ts -+++ b/packages/desktop-electron/src/preload/index.ts -@@ -1,5 +1,5 @@ - import { contextBridge, ipcRenderer } from "electron" --import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" -+import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" - - const api: ElectronAPI = { - killSidecar: () => ipcRenderer.invoke("kill-sidecar"), -@@ -11,15 +11,35 @@ const api: ElectronAPI = { - ipcRenderer.removeListener("init-step", handler) - }) - }, -+ wslServers: { -+ getState: () => ipcRenderer.invoke("wsl-servers-get-state"), -+ subscribe: (cb) => { -+ const handler = (_: unknown, event: WslServersEvent) => cb(event) -+ ipcRenderer.on("wsl-servers-event", handler) -+ return () => ipcRenderer.removeListener("wsl-servers-event", handler) -+ }, -+ 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), -+ stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id), -+ cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"), -+ updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks), -+ }, - 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), -+ wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro), - 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), -diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts -index f8e6d52c7d..18183868ad 100644 ---- a/packages/desktop-electron/src/preload/types.ts -+++ b/packages/desktop-electron/src/preload/types.ts -@@ -4,11 +4,120 @@ export type ServerReadyData = { - url: string - username: string | null - password: string | null -+ local: { -+ key: string -+ url: string -+ username: string | null -+ password: string | null -+ } - } - - export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } - --export type WslConfig = { enabled: boolean } -+export type WslServerStep = "wsl" | "distro" | "opencode" -+ -+export type WslRuntimeCheck = { -+ available: boolean -+ version: string | null -+ status: string | null -+ error: string | null -+} -+export type WslInstalledDistro = { -+ name: string -+ state: string | null -+ version: number | null -+ isDefault: boolean -+} -+export type WslOnlineDistro = { -+ name: string -+ label: string -+} -+export type WslDistroProbe = { -+ name: string -+ canExecute: boolean -+ hasBash: boolean -+ hasCurl: boolean -+ username: string | null -+ isRoot: boolean | null -+ 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 WslTranscriptLine = { -+ stream: "stdout" | "stderr" | "system" -+ text: string -+ at: number -+} -+ -+export type WslServerAcknowledgements = { -+ root: boolean -+ mismatch: { path: string; version: string } | null -+} -+ -+export type WslServerConfig = { -+ id: string -+ distro: string -+ acknowledgements: WslServerAcknowledgements -+} -+ -+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 -+ opencodeChecks: Record -+ pendingRestart: boolean -+ servers: WslServerItem[] -+ job: WslJob | null -+ transcript: WslTranscriptLine[] -+ lastError: string | null -+} -+export type WslServersEvent = { type: "state"; state: WslServersState } -+ -+export type WslServersAPI = { -+ getState: () => Promise -+ subscribe: (cb: (event: WslServersEvent) => void) => () => void -+ probeRuntime: () => Promise -+ refreshDistros: () => Promise -+ installWsl: () => Promise -+ installDistro: (name: string) => Promise -+ probeDistro: (name: string) => Promise -+ probeOpencode: (name: string) => Promise -+ installOpencode: (name: string) => Promise -+ openTerminal: (name: string) => Promise -+ addServer: (distro: string) => Promise -+ removeServer: (id: string) => Promise -+ startServer: (id: string) => Promise -+ stopServer: (id: string) => Promise -+ cancelJob: () => Promise -+ updateAcknowledgements: (id: string, acks: Partial) => Promise -+} - - export type LinuxDisplayBackend = "wayland" | "auto" - export type TitlebarTheme = { -@@ -19,15 +128,14 @@ export type ElectronAPI = { - killSidecar: () => Promise - installCli: () => Promise - awaitInitialization: (onStep: (step: InitStep) => void) => Promise -+ wslServers: WslServersAPI - getDefaultServerUrl: () => Promise - setDefaultServerUrl: (url: string | null) => Promise -- getWslConfig: () => Promise -- setWslConfig: (config: WslConfig) => Promise - getDisplayBackend: () => Promise - setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise - parseMarkdownCommand: (markdown: string) => Promise - checkAppExists: (appName: string) => Promise -- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise -+ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise - resolveAppPath: (appName: string) => Promise - storeGet: (name: string, key: string) => Promise - storeSet: (name: string, key: string, value: string) => Promise -diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts -index d1590ff048..3dbd50f61a 100644 ---- a/packages/desktop-electron/src/renderer/env.d.ts -+++ b/packages/desktop-electron/src/renderer/env.d.ts -@@ -5,8 +5,8 @@ declare global { - api: ElectronAPI - __OPENCODE__?: { - updaterEnabled?: boolean -- wsl?: boolean - deepLinks?: string[] -+ activeServer?: string - } - } - } -diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx -index 44f2e6360c..7aae903485 100644 ---- a/packages/desktop-electron/src/renderer/index.tsx -+++ b/packages/desktop-electron/src/renderer/index.tsx -@@ -1,5 +1,57 @@ - // @refresh reload - -+// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so -+// reported errors come with a useful frame budget. -+Error.stackTraceLimit = 200 -+ -+// Install global error listeners before any other module runs so that -+// uncaught errors and rejected promises reach the main process with their -+// full stacks intact. Electron's `console-message` event only forwards the -+// rethrow site, so without these we lose the originating frame. -+window.addEventListener("error", (event) => { -+ const err = event.error -+ const stack = err instanceof Error ? err.stack : null -+ console.error( -+ "[renderer uncaught]", -+ stack ?? event.message, -+ stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`, -+ ) -+}) -+ -+window.addEventListener("unhandledrejection", (event) => { -+ const reason = event.reason -+ // Log as much as possible: stack for Errors, JSON for plain objects with -+ // a fallback to a tagged shape so we never end up with just -+ // "[object Object]" in main.log. -+ if (reason instanceof Error) { -+ console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason)) -+ return -+ } -+ let serialized: string -+ try { -+ serialized = JSON.stringify( -+ reason, -+ (_key, value) => { -+ if (value instanceof Error) { -+ return { __error: true, name: value.name, message: value.message, stack: value.stack } -+ } -+ return value -+ }, -+ 2, -+ ) -+ } catch { -+ serialized = String(reason) -+ } -+ console.error( -+ "[renderer unhandled rejection]", -+ `type=${typeof reason}`, -+ `ctor=${reason?.constructor?.name ?? "null"}`, -+ `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`, -+ "value:", -+ serialized, -+ ) -+}) -+ - import { - ACCEPTED_FILE_EXTENSIONS, - ACCEPTED_FILE_TYPES, -@@ -13,16 +65,20 @@ import { - PlatformProvider, - ServerConnection, - useCommand, -+ type WslServersEvent, -+ type WslServersState, - } from "@opencode-ai/app" - import type { AsyncStorage } from "@solid-primitives/storage" - import { MemoryRouter } from "@solidjs/router" --import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" -+import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" - import { render } from "solid-js/web" - import pkg from "../../package.json" - import { initI18n, t } from "./i18n" - import { UPDATER_ENABLED } from "./updater" --import { webviewZoom } from "./webview-zoom" -+import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom" - import "./styles.css" -+import { Button } from "@opencode-ai/ui/button" -+import { Splash } from "@opencode-ai/ui/logo" - import { useTheme } from "@opencode-ai/ui/theme" - - const root = document.getElementById("root") -@@ -48,6 +104,21 @@ const listenForDeepLinks = () => { - return window.api.onDeepLink((urls) => emitDeepLinks(urls)) - } - -+function LocalServerStartupError(props: { message: string }) { -+ return ( -+
-+
-+ -+

Local Server failed to start

-+

{props.message}

-+ -+
-+
-+ ) -+} -+ - const createPlatform = (): Platform => { - const os = (() => { - const ua = navigator.userAgent -@@ -57,17 +128,25 @@ const createPlatform = (): Platform => { - return undefined - })() - -+ const activeWslDistro = () => { -+ const key = window.__OPENCODE__?.activeServer -+ if (!key || !key.startsWith("wsl:")) return undefined -+ return key.slice("wsl:".length) -+ } -+ - const wslHome = async () => { -- if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined -- return window.api.wslPath("~", "windows").catch(() => undefined) -+ const distro = activeWslDistro() -+ if (!distro) return undefined -+ return window.api.wslPath("~", "windows", distro).catch(() => undefined) - } - - const handleWslPicker = async (result: T | null): Promise => { -- if (!result || !window.__OPENCODE__?.wsl) return result -+ const distro = activeWslDistro() -+ if (!result || !distro) return result - if (Array.isArray(result)) { -- return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any -+ return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any - } -- return window.api.wslPath(result, "linux").catch(() => result) as any -+ return window.api.wslPath(result, "linux", distro).catch(() => result) as any - } - - const storage = (() => { -@@ -97,6 +176,8 @@ const createPlatform = (): Platform => { - } - })() - -+ const wslServersApi = os === "windows" ? window.api.wslServers : undefined -+ - return { - platform: "desktop", - os, -@@ -137,8 +218,9 @@ const createPlatform = (): Platform => { - if (os === "windows") { - const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null - const resolvedPath = await (async () => { -- if (window.__OPENCODE__?.wsl) { -- const converted = await window.api.wslPath(path, "windows").catch(() => null) -+ const distro = activeWslDistro() -+ if (distro) { -+ const converted = await window.api.wslPath(path, "windows", distro).catch(() => null) - if (converted) return converted - } - return path -@@ -194,16 +276,6 @@ const createPlatform = (): Platform => { - return fetch(input, init) - }, - -- getWslEnabled: async () => { -- const next = await window.api.getWslConfig().catch(() => null) -- if (next) return next.enabled -- return window.__OPENCODE__!.wsl ?? false -- }, -- -- setWslEnabled: async (enabled) => { -- await window.api.setWslConfig({ enabled }) -- }, -- - getDefaultServer: async () => { - const url = await window.api.getDefaultServerUrl().catch(() => null) - if (!url) return null -@@ -214,6 +286,8 @@ const createPlatform = (): Platform => { - await window.api.setDefaultServerUrl(url) - }, - -+ wslServers: wslServersApi, -+ - getDisplayBackend: async () => { - return window.api.getDisplayBackend().catch(() => null) - }, -@@ -243,6 +317,9 @@ const createPlatform = (): Platform => { - - let menuTrigger = null as null | ((id: string) => void) - window.api.onMenuCommand((id) => { -+ if (id === "zoom.in") return zoomIn() -+ if (id === "zoom.out") return zoomOut() -+ if (id === "zoom.reset") return zoomReset() - menuTrigger?.(id) - }) - listenForDeepLinks() -@@ -263,8 +340,19 @@ render(() => { - - const [windowCount] = createResource(() => window.api.getWindowCount()) - -- // Fetch sidecar credentials (available immediately, before health check) -- const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) -+ const [startup] = createResource(async () => { -+ try { -+ return { -+ error: null, -+ sidecar: await window.api.awaitInitialization(() => undefined), -+ } -+ } catch (error) { -+ return { -+ error: error instanceof Error ? error.message : String(error), -+ sidecar: null, -+ } -+ } -+ }) - - const [defaultServer] = createResource(() => - platform.getDefaultServer?.().then((url) => { -@@ -273,20 +361,52 @@ render(() => { - ) - const [locale] = createResource(loadLocale) - -+ const [wslServers, setWslServers] = createSignal(null) -+ if (platform.wslServers) { -+ void platform.wslServers.getState().then((state) => setWslServers(state)) -+ const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) -+ onCleanup(off) -+ } -+ - const servers = () => { -- const data = 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, -- }, -+ const data = startup.latest?.sidecar -+ const list: ServerConnection.Any[] = [] -+ if (data) { -+ list.push({ -+ displayName: "Local Server", -+ type: "sidecar", -+ variant: "base", -+ http: { -+ url: data.local.url, -+ username: data.local.username ?? undefined, -+ password: data.local.password ?? undefined, -+ }, -+ }) -+ } -+ const wsl = wslServers() -+ if (wsl) { -+ for (const item of wsl.servers) { -+ const runtime = item.runtime -+ const http = -+ runtime.kind === "ready" -+ ? { -+ url: runtime.url, -+ username: runtime.username ?? undefined, -+ password: runtime.password ?? undefined, -+ } -+ : { -+ url: `http://wsl-${item.config.distro}.invalid`, -+ } -+ list.push({ -+ displayName: `WSL: ${item.config.distro}`, -+ type: "sidecar", -+ variant: "wsl", -+ distro: item.config.distro, -+ http, -+ }) -+ } - } -- return [server] as ServerConnection.Any[] -+ return list - } - - function handleClick(e: MouseEvent) { -@@ -325,11 +445,17 @@ render(() => { - return ( - - -- -+ - {(_) => { -+ if (startup.latest?.error) { -+ return -+ } - return ( - -diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts -index 9c0a3a3a35..6ff35e2459 100644 ---- a/packages/desktop-electron/src/renderer/webview-zoom.ts -+++ b/packages/desktop-electron/src/renderer/webview-zoom.ts -@@ -11,28 +11,73 @@ const OS_NAME = (() => { - return "unknown" - })() - --const [webviewZoom, setWebviewZoom] = createSignal(1) -+const MIN_ZOOM = 0.2 -+const MAX_ZOOM = 10 -+const KEY_STEP = 0.2 -+const WHEEL_STEP = 0.1 - --const MAX_ZOOM_LEVEL = 10 --const MIN_ZOOM_LEVEL = 0.2 -+const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM) - --const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) -+const [webviewZoom, setWebviewZoom] = createSignal(1) - --const applyZoom = (next: number) => { -- setWebviewZoom(next) -- void window.api.setZoomFactor(next) -+const apply = (next: number) => { -+ const clamped = clamp(next) -+ if (Math.abs(clamped - webviewZoom()) < 1e-6) return -+ setWebviewZoom(clamped) -+ void window.api.setZoomFactor(clamped).catch(() => undefined) - } - --window.addEventListener("keydown", (event) => { -- if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return -+export const zoomIn = () => apply(webviewZoom() + KEY_STEP) -+export const zoomOut = () => apply(webviewZoom() - KEY_STEP) -+export const zoomReset = () => apply(1) - -- let newZoom = webviewZoom() -+// Seed the signal from the main process so renderer and webContents agree -+// across cold starts, reloads, and HMR refreshes (which would otherwise -+// reinitialize the signal to 1 while webContents kept its prior factor). -+void window.api -+ .getZoomFactor() -+ .then((initial) => { -+ if (typeof initial === "number" && Number.isFinite(initial)) { -+ setWebviewZoom(clamp(initial)) -+ } -+ }) -+ .catch(() => undefined) - -- if (event.key === "-") newZoom -= 0.2 -- if (event.key === "=" || event.key === "+") newZoom += 0.2 -- if (event.key === "0") newZoom = 1 -+// Keyboard accelerators. preventDefault stops Chromium's built-in zoom -+// accelerators from firing in parallel (which previously caused races). -+window.addEventListener("keydown", (event) => { -+ const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey -+ if (!mod || event.altKey) return - -- applyZoom(clamp(newZoom)) -+ if (event.key === "-" || event.key === "_") { -+ event.preventDefault() -+ zoomOut() -+ return -+ } -+ if (event.key === "=" || event.key === "+") { -+ event.preventDefault() -+ zoomIn() -+ return -+ } -+ if (event.key === "0") { -+ event.preventDefault() -+ zoomReset() -+ return -+ } - }) - -+// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad -+// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom -+// as well as real ctrl+scroll / cmd+scroll. -+window.addEventListener( -+ "wheel", -+ (event) => { -+ if (!event.ctrlKey && !event.metaKey) return -+ event.preventDefault() -+ const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP -+ apply(webviewZoom() + step) -+ }, -+ { passive: false }, -+) -+ - export { webviewZoom } -diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx -index d6a0ad74f8..875c0bcd60 100644 ---- a/packages/desktop/src/index.tsx -+++ b/packages/desktop/src/index.tsx -@@ -485,7 +485,7 @@ render(() => { - {(_) => { - return ( - - -diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css -index 1e74763ae2..db6c750f9b 100644 ---- a/packages/ui/src/components/dialog.css -+++ b/packages/ui/src/components/dialog.css -@@ -35,7 +35,7 @@ - width: 100%; - max-height: 100%; - min-height: 280px; -- overflow: auto; -+ overflow: hidden; - pointer-events: auto; - - /* Hide scrollbar */ -@@ -102,7 +102,8 @@ - display: flex; - flex-direction: column; - flex: 1; -- overflow: hidden; -+ min-height: 0; -+ overflow-y: auto; - - &:focus-visible { - outline: none; -diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx -index 981e3f45d7..39003f3b68 100644 ---- a/packages/ui/src/components/dialog.tsx -+++ b/packages/ui/src/components/dialog.tsx -@@ -1,6 +1,7 @@ - import { Dialog as Kobalte } from "@kobalte/core/dialog" --import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" -+import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" - import { useI18n } from "../context/i18n" -+import { DialogContext } from "../context/dialog" - import { IconButton } from "./icon-button" - - export interface DialogProps extends ParentProps { -@@ -12,10 +13,19 @@ export interface DialogProps extends ParentProps { - classList?: ComponentProps<"div">["classList"] - fit?: boolean - transition?: boolean -+ // When `false`, clicking the overlay or outside the dialog will not dismiss it. -+ // Default is `true`. -+ dismissOutside?: boolean - } - - export function Dialog(props: DialogProps) { - const i18n = useI18n() -+ const dialogCtx = useContext(DialogContext) -+ createEffect(() => { -+ if (!dialogCtx) return -+ if (props.dismissOutside === undefined) return -+ dialogCtx.active?.setDismissOutside(props.dismissOutside) -+ }) - return ( -
void - setClosing: (closing: boolean) => void -+ dismissOutside: () => boolean -+ setDismissOutside: (value: boolean) => void - } - - const Context = createContext>() - -+export const DialogContext = Context -+ - function init() { - const [active, setActive] = createSignal() - const timer = { current: undefined as ReturnType | undefined } -@@ -89,12 +93,17 @@ function init() { - const id = Math.random().toString(36).slice(2) - let dispose: (() => void) | undefined - let setClosing: ((closing: boolean) => void) | undefined -+ let setDismissOutsideSignal: ((value: boolean) => void) | undefined -+ let dismissOutsideAccessor: (() => boolean) | undefined - - const node = runWithOwner(owner, () => - createRoot((d: () => void) => { - dispose = d - const [closing, setClosingSignal] = createSignal(false) - setClosing = setClosingSignal -+ const [dismissOutside, setDismissOutside] = createSignal(true) -+ dismissOutsideAccessor = dismissOutside -+ setDismissOutsideSignal = setDismissOutside - return ( - - -- -+ { -+ if (dismissOutside()) close() -+ }} -+ /> - {element()} - - -@@ -113,9 +127,18 @@ function init() { - }), - ) - -- if (!dispose || !setClosing) return -- -- setActive({ id, node, dispose, owner, onClose, setClosing }) -+ if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return -+ -+ setActive({ -+ id, -+ node, -+ dispose, -+ owner, -+ onClose, -+ setClosing, -+ dismissOutside: dismissOutsideAccessor, -+ setDismissOutside: setDismissOutsideSignal, -+ }) - } - - return { -@@ -159,5 +182,8 @@ export function useDialog() { - close() { - ctx.close() - }, -+ setDismissOutside(value: boolean) { -+ ctx.active?.setDismissOutside(value) -+ }, - } - } From 0e041418495ebcde09910411d78a1a8920eea79f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:35:43 +1000 Subject: [PATCH 66/88] tinkering --- packages/app/src/app.tsx | 13 ++- .../src/components/dialog-select-server.tsx | 82 +++++++++++++++---- .../app/src/components/dialog-wsl-server.tsx | 76 +++++++++-------- .../src/components/status-popover-body.tsx | 38 +++------ packages/app/src/context/global-sdk.tsx | 15 +++- packages/app/src/utils/server-switch.tsx | 23 ++++++ packages/desktop-electron/src/main/wsl.ts | 11 ++- 7 files changed, 173 insertions(+), 85 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5528523ab999..326db25cb8c7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -14,6 +14,7 @@ import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { + batch, type Component, createMemo, createResource, @@ -28,7 +29,7 @@ import { Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" -import { serverSwitching } from "@/utils/server-switch" +import { serverSwitching, withServerSwitchOverlay } from "@/utils/server-switch" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -212,9 +213,13 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { if (checkMode() === "background") void healthCheckActions.refetch() }} onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() + void withServerSwitchOverlay(() => { + batch(() => { + setCheckMode("blocking") + server.setActive(key) + }) + void healthCheckActions.refetch() + }) }} /> } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 9fafcc1cd97d..200b489d96d6 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -17,6 +17,7 @@ import type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { withServerSwitchOverlay } from "@/utils/server-switch" const DEFAULT_USERNAME = "opencode" const cachedServerStatus = new Map() @@ -223,6 +224,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, addWsl: { showWizard: props.initialView === "add-wsl", + pendingSelectKey: undefined as ServerConnection.Key | undefined, }, editServer: { id: undefined as string | undefined, @@ -345,6 +347,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + let resolvePendingWslSelection: VoidFunction | undefined const healthPollKey = createMemo(() => items() .map((conn) => @@ -428,29 +431,50 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function select(conn: ServerConnection.Any, persist?: boolean) { if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return - dialog.close() const nextKey = ServerConnection.key(conn) const changed = server.key !== nextKey - - if (persist && conn.type === "http") { - server.add(conn) - if (changed && typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } else { - props.onNavigateHome?.() + + const apply = () => { + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } + return } + + batch(() => { + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } + server.setActive(nextKey) + }) + } + + if (!changed) { + apply() return } - batch(() => { - if (changed && typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } else { - props.onNavigateHome?.() - } - server.setActive(nextKey) - }) + + await withServerSwitchOverlay(apply) } + createEffect(() => { + const key = store.addWsl.pendingSelectKey + if (!key) return + const conn = items().find((item) => ServerConnection.key(item) === key) + if (!conn) return + const resolve = resolvePendingWslSelection + resolvePendingWslSelection = undefined + setStore("addWsl", "pendingSelectKey", undefined) + void select(conn).finally(() => resolve?.()) + }) + const handleAddChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) @@ -524,6 +548,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const resetForm = () => { resetAdd() resetEdit() + resolvePendingWslSelection?.() + resolvePendingWslSelection = undefined + setStore("addWsl", "pendingSelectKey", undefined) setStore("addWsl", "showWizard", false) } @@ -558,9 +585,23 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const startAddWsl = () => { resetAdd() resetEdit() + setStore("addWsl", "pendingSelectKey", undefined) setStore("addWsl", "showWizard", true) } + const handleAddedWsl = async (distro: string) => { + const key = ServerConnection.Key.make(`wsl:${distro}`) + const conn = items().find((item) => ServerConnection.key(item) === key) + if (conn) { + await select(conn) + return + } + await new Promise((resolve) => { + resolvePendingWslSelection = resolve + setStore("addWsl", "pendingSelectKey", key) + }) + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -646,7 +687,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } return ( - +
} > - + } > diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index a396098a21f1..4c4cf952c688 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -23,7 +23,7 @@ function parseProgressPercent(text: string) { } interface DialogWslServerProps { - onAdded?: () => void + onAdded?: (distro: string) => void | Promise } export function DialogWslServer(props: DialogWslServerProps = {}) { @@ -272,7 +272,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const selectDistro = (name: string) => { setStore("selectedDistro", name) - setStore("step", "distro") + setStore("step", undefined) } const finish = async () => { @@ -283,8 +283,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { setStore("adding", true) try { await api.addServer(distro) - props.onAdded?.() - dialog.close() + if (props.onAdded) { + await props.onAdded(distro) + } else { + dialog.close() + } } catch (err) { requestError(language, err) } finally { @@ -356,6 +359,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
+
+ +
@@ -386,11 +394,9 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { onClick={() => selectDistro(item.name)} >
{item.name}
-
- {[item.isDefault ? "default" : null, item.state, item.version ? `WSL ${item.version}` : null] - .filter(Boolean) - .join(" · ")} -
+ +
Default
+
)} @@ -423,7 +429,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
{(item) => { @@ -434,7 +440,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { role="radio" aria-checked={selected()} disabled={busy()} - class="w-full px-3 py-2 flex items-start gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors" + 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(), @@ -447,12 +453,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { >
-
-
{item.label}
- -
{item.name}
-
-
+
{item.label}
) }} @@ -499,6 +500,17 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { > Open terminal + +
+ +
@@ -550,10 +562,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
Progress
{progress().title}
-
+
0}>
Diagnostics
-
+
{(line) =>
{line.text}
}
-
- - -
+ +
+ + +
+
) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 1034f0676d58..f4ccb1b9546a 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -15,7 +15,7 @@ import { useSDK } from "@/context/sdk" 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" +import { withServerSwitchOverlay } from "@/utils/server-switch" const pollMs = 10_000 @@ -289,32 +289,18 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - // Paint a full-window splash BEFORE the heavy - // ServerKey remount so the user gets visual - // feedback during the multi-second synchronous - // dispose cascade (xterm + file-tree + providers). - // setTimeout(0) yields to the browser so the - // splash lands on screen before the cascade - // starts; a second setTimeout(0) after the batch - // waits for the new subtree to paint, then - // dismisses the splash. - setServerSwitching(true) - setTimeout(() => { - try { - batch(() => { - if (server.key !== key) { - if (typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } - } else { - navigate("/") + void withServerSwitchOverlay(() => { + batch(() => { + if (server.key !== key) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") } - server.setActive(key) - }) - } finally { - setTimeout(() => setServerSwitching(false), 0) - } - }, 0) + } else { + navigate("/") + } + server.setActive(key) + }) + }) }} > diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 973ecc66bc24..5666442a4d2e 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -95,6 +95,15 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo buffer.length = 0 } + const clearPending = () => { + if (timer) clearTimeout(timer) + timer = undefined + queue.length = 0 + buffer.length = 0 + coalesced.clear() + staleDeltas.clear() + } + const schedule = () => { if (timer) return const elapsed = Date.now() - last @@ -202,6 +211,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })().finally(() => { run = undefined + if (abort.signal.aborted || !started) { + clearPending() + return + } flush() }) return run @@ -225,7 +238,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo onCleanup(() => { stop() abort.abort() - flush() + clearPending() }) const sdk = createSdkForServer({ diff --git a/packages/app/src/utils/server-switch.tsx b/packages/app/src/utils/server-switch.tsx index 480990b18477..7c9b0efb77c2 100644 --- a/packages/app/src/utils/server-switch.tsx +++ b/packages/app/src/utils/server-switch.tsx @@ -7,3 +7,26 @@ import { createSignal } from "solid-js" // swap and false after lets us render an overlay above the ServerKey // boundary so the freeze has visual feedback instead of looking stuck. export const [serverSwitching, setServerSwitching] = createSignal(false) + +let run = 0 + +const nextPaint = () => + new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()) + return + } + setTimeout(resolve, 0) + }) + +export async function withServerSwitchOverlay(action: () => void | Promise) { + const token = ++run + setServerSwitching(true) + await nextPaint() + try { + await action() + } finally { + await nextPaint() + if (run === token) setServerSwitching(false) + } +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 1785df0f13d6..abfc8a9ca6b0 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { join } from "node:path" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" import { runInteractiveCommand } from "./wsl-pty" @@ -320,7 +322,7 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) { export async function installWslDistro(name: string, opts?: RunWslOptions) { return runInteractiveCommand( - "wsl", + resolveSystem32Command("wsl.exe"), ["--install", "-d", name, "--web-download", "--no-launch"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, @@ -520,6 +522,13 @@ 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, From a17ce350f185fdcd75a5fbeeb54544731b9fed1a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:59:38 +1000 Subject: [PATCH 67/88] tinkering --- packages/app/src/app.tsx | 118 ++++++++++-------- .../src/components/dialog-select-server.tsx | 29 +++-- .../src/components/status-popover-body.tsx | 18 ++- packages/app/src/context/server.tsx | 40 +++--- packages/app/src/pages/home.tsx | 3 +- packages/app/src/pages/layout.tsx | 3 +- packages/app/src/utils/server-health.ts | 9 ++ 7 files changed, 138 insertions(+), 82 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 326db25cb8c7..921ee1a64991 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -16,6 +16,7 @@ import { Effect } from "effect" import { batch, type Component, + createEffect, createMemo, createResource, createSignal, @@ -49,7 +50,7 @@ import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" -import { useCheckServerHealth } from "./utils/server-health" +import { isPlaceholderServerUrl, useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) const loadSession = () => import("@/pages/session") @@ -166,67 +167,80 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checkServerHealth = useCheckServerHealth() const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + const healthTarget = createMemo(() => { + const current = server.current + if (props.disableHealthCheck || !current) return "" + return [ + ServerConnection.key(current), + current.type, + current.http.url, + current.http.username ?? "", + current.http.password ?? "", + ].join("\n") + }) + + createEffect(() => { + healthTarget() + setCheckMode("blocking") + }) // performs repeated health check with a grace period for // non-http connections, otherwise fails instantly - const [startupHealthCheck, healthCheckActions] = createResource(() => - props.disableHealthCheck - ? true - : Effect.gen(function* () { - if (!server.current) return true - const { http, type } = server.current + const [startupHealthCheck, healthCheckActions] = createResource( + healthTarget, + () => + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + if (isPlaceholderServerUrl(http.url)) return false - while (true) { - const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true - if (checkMode() === "background" || type === "http") return false - } - }).pipe( - Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), - Effect.ensuring(Effect.sync(() => setCheckMode("background"))), - Effect.runPromise, - ), + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), + ) + + const splash = ( +
+ +
) return ( - - -
- } - > - {/* - -
- } - >*/} - {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} + { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - void withServerSwitchOverlay(() => { - batch(() => { - setCheckMode("blocking") - server.setActive(key) - }) - void healthCheckActions.refetch() - }) - }} - /> - } + when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} + fallback={splash} > - {props.children} + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + void withServerSwitchOverlay(() => { + batch(() => { + setCheckMode("blocking") + server.setActive(key) + }) + }) + }} + /> + } + > + {props.children} + - {/**/} ) } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 200b489d96d6..10c533bef90c 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -16,7 +16,7 @@ import { useLanguage } from "@/context/language" import type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { isPlaceholderServerUrl, type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { withServerSwitchOverlay } from "@/utils/server-switch" const DEFAULT_USERNAME = "opencode" @@ -356,6 +356,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { .join("\n\n"), ) const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) + const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url) + const wslRuntime = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + return store.wslState?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime + } + const canRetryWsl = (conn: ServerConnection.Any) => { + const runtime = wslRuntime(conn) + return runtime?.kind === "failed" || runtime?.kind === "stopped" + } const sortedItems = createMemo(() => { const list = items() @@ -378,8 +387,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function refreshHealth() { const results: Record = {} + const list = untrack(items) await Promise.all( - items().map(async (conn) => { + list.map(async (conn) => { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) @@ -430,6 +440,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!isSelectable(conn)) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return const nextKey = ServerConnection.key(conn) const changed = server.key !== nextKey @@ -468,7 +479,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const key = store.addWsl.pendingSelectKey if (!key) return const conn = items().find((item) => ServerConnection.key(item) === key) - if (!conn) return + if (!conn || !isSelectable(conn)) return const resolve = resolvePendingWslSelection resolvePendingWslSelection = undefined setStore("addWsl", "pendingSelectKey", undefined) @@ -592,7 +603,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleAddedWsl = async (distro: string) => { const key = ServerConnection.Key.make(`wsl:${distro}`) const conn = items().find((item) => ServerConnection.key(item) === key) - if (conn) { + if (conn && isSelectable(conn)) { await select(conn) return } @@ -741,8 +752,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const key = ServerConnection.key(i) const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined - const hasMenuActionsBeforeDelete = () => - i.type === "http" || (isWslSidecar && health(key)?.healthy === false) + const blocked = () => !isSelectable(i) || health(key)?.healthy === false + const hasMenuActionsBeforeDelete = () => i.type === "http" || (isWslSidecar && canRetryWsl(i)) const outdated = () => { const check = wslCheck(i) return versionOlderThan(check?.version, check?.expectedVersion) @@ -763,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
{language.t("dialog.server.menu.edit")} - + void handleRetryWsl(i)}> Retry start diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index f4ccb1b9546a..bc257df61892 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" @@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { isPlaceholderServerUrl, useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { withServerSwitchOverlay } from "@/utils/server-switch" const pollMs = 10_000 @@ -56,13 +56,23 @@ const listServersByHealth = ( const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) + const pollKey = createMemo(() => + enabled() + ? servers() + .map((conn) => + [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"), + ) + .join("\n\n") + : "", + ) createEffect(() => { if (!enabled()) { setStatus(reconcile({})) return } - const list = servers() + pollKey() + const list = untrack(servers) let dead = false const refresh = async () => { @@ -277,7 +287,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {(s) => { const key = ServerConnection.key(s) - const blocked = () => health[key]?.healthy === false + const blocked = () => isPlaceholderServerUrl(s.http.url) || health[key]?.healthy === false return ( - )} - -
+ Loading...
}> + {loadError()}
}> +
+ + {(item) => ( + + )} + +
- +
@@ -344,7 +395,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy()} - onClick={() => void run(() => wslServers()!.installWsl())} + onClick={() => void run(() => installWslMutation.mutateAsync())} > Install WSL @@ -355,7 +406,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
Windows restart required.
@@ -369,7 +420,23 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
-
Choose a distro
+
+
Choose a distro
+ + + +
{distroMessage()}
@@ -420,7 +487,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="small" disabled={busy() || !installTarget()} - onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))} + onClick={() => void run(() => installDistroMutation.mutateAsync(installTarget()!.name))} > {installingDistro() ? "Installing..." : "Install"} @@ -488,18 +555,32 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
- +
+ + +
- +
+ + + + + + +
{opencodeMessage()}
@@ -552,58 +649,59 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
- + + + + {(progress) => ( +
+
+ +
Progress
+
+
{progress().title}
+
+ + {(line) => ( +
+ {line.text} +
+ )} +
+
+
+ )} +
- - {(progress) => ( + 0}>
-
- -
Progress
-
-
{progress().title}
-
- - {(line) => ( -
- {line.text} -
- )} -
+
Diagnostics
+
+ {(line) =>
{line.text}
}
- )} - + - 0}> -
-
Diagnostics
-
- {(line) =>
{line.text}
}
+ +
+ +
-
- - - -
- - -
+
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index bc257df61892..290ec35194b1 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,11 +6,11 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, untrack } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useDefaultServer } from "@/context/default-server" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" @@ -97,49 +97,6 @@ const useServerHealth = (servers: Accessor, enabled: Acc return status } -const useDefaultServerKey = ( - get: (() => string | Promise | null | undefined) | undefined, -) => { - const [state, setState] = createStore({ - key: undefined as ServerConnection.Key | undefined, - tick: 0, - }) - - createEffect(() => { - state.tick - let dead = false - const result = get?.() - if (!result) { - setState("key", undefined) - onCleanup(() => { - dead = true - }) - return - } - - if (result instanceof Promise) { - void result.then((next) => { - if (dead) return - setState("key", next ? ServerConnection.Key.make(next) : undefined) - }) - onCleanup(() => { - dead = true - }) - return - } - - setState("key", ServerConnection.Key.make(result)) - onCleanup(() => { - dead = true - }) - }) - - return { - key: () => state.key, - refresh: () => setState("tick", (value) => value + 1), - } -} - const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() @@ -165,11 +122,11 @@ const useMcpToggleMutation = () => { export function StatusPopoverBody(props: { shown: Accessor }) { const sync = useSync() const server = useServer() - const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() const sdk = useSDK() + const defaultServer = useDefaultServer() const [load, setLoad] = createStore({ lspDone: false, @@ -240,7 +197,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const health = useServerHealth(servers, props.shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() - const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) @@ -296,23 +252,25 @@ export function StatusPopoverBody(props: { shown: Accessor }) { "hover:bg-surface-raised-base-hover": !blocked(), "cursor-not-allowed": blocked(), }} - aria-disabled={blocked()} - onClick={() => { - if (blocked()) return - void withServerSwitchOverlay(() => { - batch(() => { - if (server.key !== key) { - if (typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } - } else { - navigate("/") - } - server.setActive(key) - }) - }) - }} - > + aria-disabled={blocked()} + onClick={() => { + if (blocked()) return + void withServerSwitchOverlay(() => + startTransition(() => { + batch(() => { + if (server.key !== key) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } + } else { + navigate("/") + } + server.setActive(key) + }) + }), + ) + }} + > }) { nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - + {language.t("common.default")} @@ -348,7 +306,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { if (dialogDead || dialogRun !== run) return dialog.show( () => navigate("/")} />, - defaultServer.refresh, + () => void defaultServer.query.refetch(), ) }) }} diff --git a/packages/app/src/context/default-server.tsx b/packages/app/src/context/default-server.tsx new file mode 100644 index 000000000000..48c360f5e157 --- /dev/null +++ b/packages/app/src/context/default-server.tsx @@ -0,0 +1,51 @@ +import { showToast } from "@opencode-ai/ui/toast" +import { queryOptions, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/solid-query" +import { useLanguage } from "./language" +import { usePlatform } from "./platform" +import { ServerConnection } from "./server" + +const defaultServerQueryKey = ["platform", "defaultServer"] as const + +function defaultServerQueryOptions(getDefaultServer: ReturnType["getDefaultServer"]) { + return queryOptions({ + queryKey: defaultServerQueryKey, + queryFn: getDefaultServer + ? () => getDefaultServer().then((next) => (next ? ServerConnection.Key.make(next) : null)) + : skipToken, + staleTime: Number.POSITIVE_INFINITY, + }) +} + +export function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const queryClient = useQueryClient() + const query = useQuery(() => ({ ...defaultServerQueryOptions(platform.getDefaultServer) })) + const mutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key | null) => { + if (!platform.setDefaultServer) return key + await platform.setDefaultServer(key) + return key + }, + onSuccess: (key) => { + queryClient.setQueryData(defaultServerQueryKey, key) + }, + onError: (err) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }, + })) + + return { + canDefault: () => !!platform.getDefaultServer && !!platform.setDefaultServer, + defaultKey: () => query.data ?? null, + query, + setDefault(key: ServerConnection.Key | null) { + if (!platform.setDefaultServer) return Promise.resolve(key) + return mutation.mutateAsync(key) + }, + } +} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 9063864c6c33..62fe040b2aa1 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -97,9 +97,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( init: (props: { defaultServer: ServerConnection.Key disableHealthCheck?: boolean + serversReady?: boolean servers?: Array }) => { const checkServerHealth = useCheckServerHealth() + const serversReady = () => props.serversReady ?? true const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), @@ -204,8 +206,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = createMemo(() => ready() && !!state.active) - const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => { if (!x.healthy) { @@ -229,14 +229,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) - const current: Accessor = createMemo( - () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], - ) + const current: Accessor = createMemo(() => { + const list = allServers() + const active = list.find((s) => ServerConnection.key(s) === state.active) + if (active) return active + if (!serversReady()) return + return list[0] + }) const healthTarget = createMemo(() => { const conn = current() if (!conn) return "" return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n") }) + const isReady = createMemo(() => ready() && !!current()) createEffect(() => { healthTarget() @@ -257,6 +262,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) createEffect(() => { + if (!serversReady()) return const list = allServers() if (!list.length) return if (list.some((conn) => ServerConnection.key(conn) === state.active)) return diff --git a/packages/app/src/context/wsl-servers.tsx b/packages/app/src/context/wsl-servers.tsx new file mode 100644 index 000000000000..0668c905f60c --- /dev/null +++ b/packages/app/src/context/wsl-servers.tsx @@ -0,0 +1,36 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createEffect, onCleanup } from "solid-js" +import type { WslServersPlatform, WslServersState } from "./platform" +import { usePlatform } from "./platform" + +export const wslServersQueryKey = ["platform", "wslServers"] as const + +export function wslServersQueryOptions(api: WslServersPlatform | undefined) { + return queryOptions({ + queryKey: wslServersQueryKey, + queryFn: api ? () => api.getState() : skipToken, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) +} + +export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({ + name: "WslServers", + init: () => { + const platform = usePlatform() + const queryClient = useQueryClient() + const query = useQuery(() => ({ ...wslServersQueryOptions(platform.wslServers) })) + + createEffect(() => { + const api = platform.wslServers + if (!api) return + const off = api.subscribe((event) => { + queryClient.setQueryData(wslServersQueryKey, event.state) + }) + onCleanup(off) + }) + + return query + }, +}) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 4173cf9ca7be..b960238e9a8e 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -3,6 +3,7 @@ export { DialogWslServer } from "./components/dialog-wsl-server" 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 { useWslServers } from "./context/wsl-servers" export { type DisplayBackend, type Platform, diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index f7342e9bea4e..be0be3cf59f3 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -437,59 +437,39 @@ render(() => { const [defaultServer] = createResource(() => platform.getDefaultServer?.().then((url) => { - if (url) return ServerConnection.key({ type: "http", http: { url } }) + if (url) return ServerConnection.Key.make(url) }), ) const [locale] = createResource(loadLocale) - - const [wslServers, setWslServers] = createSignal(null) + const [storedServers] = createResource(async () => { + const raw = await platform.storage?.("opencode.global.dat").getItem("server") + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as { list?: unknown } + return Array.isArray(parsed.list) ? parsed.list : [] + } catch { + return [] + } + }) + const [wslServers, setWslServers] = createSignal() + const [wslReady, setWslReady] = createSignal(!platform.wslServers) if (platform.wslServers) { - void platform.wslServers.getState().then((state) => setWslServers(state)) - const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) + void platform.wslServers + .getState() + .then((state) => { + setWslServers(state) + setWslReady(true) + }) + .catch(() => { + setWslReady(true) + }) + const off = platform.wslServers.subscribe((event: WslServersEvent) => { + setWslServers(event.state) + setWslReady(true) + }) onCleanup(off) } - const servers = createMemo(() => { - const data = startup.latest?.sidecar - const list: ServerConnection.Any[] = [] - if (data) { - list.push({ - displayName: "Local Server", - type: "sidecar", - variant: "base", - http: { - url: data.local.url, - username: data.local.username ?? undefined, - password: data.local.password ?? undefined, - }, - }) - } - const wsl = wslServers() - if (wsl) { - for (const item of wsl.servers) { - const runtime = item.runtime - const http = - runtime.kind === "ready" - ? { - url: runtime.url, - username: runtime.username ?? undefined, - password: runtime.password ?? undefined, - } - : { - url: `http://wsl-${item.config.distro}.invalid`, - } - list.push({ - displayName: item.config.distro, - type: "sidecar", - variant: "wsl", - distro: item.config.distro, - http, - }) - } - } - return list - }) - function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -516,6 +496,76 @@ render(() => { return null } + function App() { + const splash = ( +
+ +
+ ) + + const ready = createMemo( + () => + !defaultServer.loading && + !startup.loading && + !windowCount.loading && + !locale.loading, + ) + const servers = createMemo(() => { + const data = startup.latest?.sidecar + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.local.url, + username: data.local.username ?? undefined, + password: data.local.password ?? undefined, + }, + }) + } + for (const item of wslServers()?.servers ?? []) { + const runtime = item.runtime + list.push({ + displayName: item.config.distro, + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + http: + runtime.kind === "ready" + ? { + url: runtime.url, + username: runtime.username ?? undefined, + password: runtime.password ?? undefined, + } + : { url: `http://wsl-${item.config.distro}.invalid` }, + }) + } + return list + }) + const hasFallbackServers = createMemo(() => { + if ((storedServers.latest?.length ?? 0) > 0) return true + return (wslServers()?.servers.length ?? 0) > 0 + }) + + if (!ready()) return splash + if (startup.latest?.error && !storedServers.loading && !hasFallbackServers()) { + return + } + + return ( + + + + ) + } + onMount(() => { document.addEventListener("click", handleClick) onCleanup(() => { @@ -526,25 +576,7 @@ render(() => { return ( - - {(_) => { - if (startup.latest?.error) { - return - } - return ( - - - - ) - }} - + ) From ed4a41f1e0b8d784bfc91a0fb62843e5a32a9b8a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:57:25 +1000 Subject: [PATCH 70/88] . --- .../src/components/dialog-select-server.tsx | 36 ++-- .../app/src/components/dialog-wsl-server.tsx | 106 +++------- packages/desktop-electron/src/main/index.ts | 62 +++++- packages/desktop-electron/src/main/ipc.ts | 198 ++++++++++++------ packages/desktop-electron/src/main/server.ts | 6 +- .../desktop-electron/src/main/wsl-servers.ts | 51 ++--- packages/desktop-electron/src/main/wsl.ts | 28 +-- .../desktop-electron/src/preload/index.ts | 6 +- .../desktop-electron/src/preload/types.ts | 2 - .../desktop-electron/src/renderer/index.tsx | 7 +- packages/desktop/src-tauri/src/server.rs | 24 +-- packages/desktop/src/index.tsx | 7 +- packages/ui/src/components/dialog.tsx | 12 +- packages/ui/src/context/dialog.tsx | 23 +- 14 files changed, 295 insertions(+), 273 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4b3aa4b4103f..8708fb8c84e4 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -47,7 +47,7 @@ function versionOlderThan(current: string | null | undefined, expected: string | } interface DialogSelectServerProps { - initialView?: "list" | "add-wsl" + initialView?: "add-wsl" onNavigateHome?: () => void } @@ -76,6 +76,10 @@ function showRequestError(language: ReturnType, err: unknown }) } +function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } { + return conn.type === "sidecar" && conn.variant === "wsl" +} + function useServerPreview() { const checkServerHealth = useCheckServerHealth() @@ -182,6 +186,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const wslServers = useWslServers() const { previewStatus } = useServerPreview() const checkServerHealth = useCheckServerHealth() + let disposed = false + onCleanup(() => { + disposed = true + }) const [store, setStore] = createStore({ status: {} as Record, addServer: { @@ -355,7 +363,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url) const wslRuntime = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime } const canRetryWsl = (conn: ServerConnection.Any) => { @@ -390,6 +398,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) + if (disposed) return for (const [key, value] of Object.entries(results)) { cachedServerStatus.set(ServerConnection.Key.make(key), value) } @@ -404,13 +413,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const wslCheck = (conn: ServerConnection.Any) => { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return null + if (!isWslSidecar(conn)) return null return wslState()?.opencodeChecks[conn.distro] ?? null } const displayVersion = (conn: ServerConnection.Any) => { - if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined - return undefined + return wslCheck(conn)?.version ?? undefined } async function select(conn: ServerConnection.Any, persist?: boolean) { @@ -630,17 +638,17 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } function handleRemoveWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return removeWslMutation.mutate(ServerConnection.key(conn)) } function handleRetryWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return retryWslMutation.mutate(ServerConnection.key(conn)) } function handleUpdateWsl(conn: ServerConnection.Any) { - if (conn.type !== "sidecar" || conn.variant !== "wsl") return + if (!isWslSidecar(conn)) return updateWslMutation.mutate(conn.distro) } @@ -697,11 +705,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { > {(i) => { const key = ServerConnection.key(i) - const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" - const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined + const wsl = isWslSidecar(i) + const wslDistro = wsl ? i.distro : undefined const blocked = () => !isSelectable(i) || health(key)?.healthy === false const canChangeDefault = () => canDefault() && i.type !== "ssh" - const canRemove = () => i.type === "http" || isWslSidecar + const canRemove = () => i.type === "http" || wsl const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i)) const outdated = () => { const check = wslCheck(i) @@ -739,7 +747,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
- + {(label) => ( @@ -560,11 +536,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy() || !selectedInstalled()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => openTerminalMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => openTerminalMutation.mutateAsync(distro))} > Open terminal @@ -572,11 +544,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy() || !selectedDistro()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => probeDistroMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))} > Refresh @@ -605,11 +573,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => probeOpencodeMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => probeOpencodeMutation.mutateAsync(distro))} > Refresh @@ -619,11 +583,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy()} - onClick={() => { - const distro = selectedDistro() - if (!distro) return - void run(() => installOpencodeMutation.mutateAsync(distro)) - }} + onClick={() => runSelectedDistro((distro) => installOpencodeMutation.mutateAsync(distro))} > {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 9c25501e5673..e1780db5f080 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -352,7 +352,7 @@ function wireMenu() { } registerIpcHandlers({ - httpFetch: (input) => bridgedHttpFetch(input), + httpFetch: (input) => bridgedHttpFetch(input, readyWslUrls()), killSidecar: () => killSidecar(), relaunch: () => relaunchApp(), awaitInitialization: async (sendStep) => { @@ -401,6 +401,12 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) +function readyWslUrls() { + return wslServers + .getState() + .servers.flatMap((item) => (item.runtime.kind === "ready" ? [item.runtime.url] : [])) +} + function killSidecar() { if (!server) return server.stop() @@ -421,13 +427,19 @@ function relaunchApp() { // 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<{ +const BRIDGED_HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) +const MAX_BRIDGED_HTTP_BODY_BYTES = 25 * 1024 * 1024 + +function bridgedHttpFetch( + input: { + url: string + method: string + headers: Record + body?: string + timeoutMs?: number + }, + allowedUrls: string[], +): Promise<{ status: number statusText: string headers: Record @@ -445,12 +457,25 @@ function bridgedHttpFetch(input: { reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`)) return } + if (!allowedUrls.some((url) => sameOrigin(parsed, url))) { + reject(new Error("httpFetch: url is not an active WSL sidecar")) + return + } + const method = input.method.toUpperCase() + if (!BRIDGED_HTTP_METHODS.has(method)) { + reject(new Error(`httpFetch: unsupported method ${input.method}`)) + return + } + if (input.body && Buffer.byteLength(input.body) > MAX_BRIDGED_HTTP_BODY_BYTES) { + reject(new Error(`httpFetch: request body exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`)) + return + } const req = nodeHttp.request({ host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 80, path: `${parsed.pathname}${parsed.search}`, - method: input.method, + method, headers: { ...input.headers, connection: "close" }, agent: false, }) @@ -468,7 +493,15 @@ function bridgedHttpFetch(input: { req.once("response", (res) => { const chunks: Buffer[] = [] - res.on("data", (chunk: Buffer) => chunks.push(chunk)) + let bytes = 0 + res.on("data", (chunk: Buffer) => { + bytes += chunk.length + if (bytes <= MAX_BRIDGED_HTTP_BODY_BYTES) { + chunks.push(chunk) + return + } + res.destroy(new Error(`httpFetch: response exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`)) + }) res.once("end", () => { const headers: Record = {} for (const [key, value] of Object.entries(res.headers)) { @@ -492,6 +525,15 @@ function bridgedHttpFetch(input: { }) } +function sameOrigin(input: URL, allowed: string) { + try { + const url = new URL(allowed) + return input.protocol === url.protocol && input.hostname === url.hostname && input.port === url.port + } catch { + return false + } +} + 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 023da7eae67d..6baeefb1f392 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -78,79 +78,147 @@ export function registerIpcHandlers(deps: Deps) { console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`) } - const offWslServers = deps.onWslServersEvent((payload) => { - for (const win of BrowserWindow.getAllWindows()) { - if (win.isDestroyed()) continue - win.webContents.send("wsl-servers-event", payload) + const requireString = (name: string, value: unknown) => { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) + } + + const trustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { + const raw = event.senderFrame?.url ?? event.sender.getURL() + try { + const url = new URL(raw) + if (url.protocol === "oc:" && url.hostname === "renderer") return true + if (!app.isPackaged && (url.hostname === "127.0.0.1" || url.hostname === "localhost")) return true + } catch { + return false } + return false + } + + const requireTrustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { + if (trustedSender(event)) return + throw new Error("Untrusted IPC sender") + } + + const handle = ( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown, + ) => { + ipcMain.handle(channel, (event, ...args) => { + requireTrustedSender(event) + return listener(event, ...(args as Args)) + }) + } + + const on = (channel: string, listener: (event: IpcMainEvent, ...args: Args) => void) => { + ipcMain.on(channel, (event, ...args) => { + if (!trustedSender(event)) return + listener(event, ...(args as Args)) + }) + } + + const wslSubscriptions = new Map void>() + const unsubscribeWsl = (id: number) => { + const off = wslSubscriptions.get(id) + if (!off) return + off() + wslSubscriptions.delete(id) + } + + app.once("will-quit", () => { + for (const off of wslSubscriptions.values()) off() + wslSubscriptions.clear() }) - app.once("will-quit", offWslServers) - ipcMain.handle( + 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) => { + handle("kill-sidecar", () => deps.killSidecar()) + handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) - ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) - ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) - ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) - ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) - ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersInstallDistro(name), + handle("wsl-servers-subscribe", (event) => { + const id = event.sender.id + if (wslSubscriptions.has(id)) return + wslSubscriptions.set( + id, + deps.onWslServersEvent((payload) => { + if (event.sender.isDestroyed()) { + unsubscribeWsl(id) + return + } + event.sender.send("wsl-servers-event", payload) + }), + ) + event.sender.once("destroyed", () => unsubscribeWsl(id)) + }) + handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id)) + handle("wsl-servers-get-state", () => deps.getWslServersState()) + handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) + handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) + handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) + handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallDistro(requireString("distro", name)), + ) + handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeDistro(requireString("distro", name)), + ) + handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersProbeOpencode(requireString("distro", name)), + ) + handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersInstallOpencode(requireString("distro", name)), + ) + handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + deps.wslServersOpenTerminal(requireString("distro", name)), ) - ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersProbeDistro(name), + handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + deps.wslServersAddServer(requireString("distro", distro)), ) - ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersProbeOpencode(name), + handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersRemoveServer(requireString("server id", id)), ) - ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersInstallOpencode(name), + handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersStartServer(requireString("server id", id)), ) - ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => - deps.wslServersOpenTerminal(name), + handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => + deps.wslServersStopServer(requireString("server id", id)), ) - ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro)) - ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id)) - ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id)) - ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id)) - ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) - ipcMain.handle( + handle("wsl-servers-cancel", () => deps.wslServersCancelJob()) + handle( "wsl-servers-update-acknowledgements", (_event: IpcMainInvokeEvent, id: string, acks: Partial) => - deps.wslServersUpdateAcknowledgements(id, acks), + deps.wslServersUpdateAcknowledgements(requireString("server id", id), acks), ) - ipcMain.handle("get-window-config", () => deps.getWindowConfig()) - ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) - ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) - ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => + handle("get-window-config", () => deps.getWindowConfig()) + handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) + handle("get-default-server-url", () => deps.getDefaultServerUrl()) + handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) - ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) - ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => + handle("get-display-backend", () => deps.getDisplayBackend()) + 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( + handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) + handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) + handle( "wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) => deps.wslPath(path, mode, distro), ) - ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) - ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) - ipcMain.handle("check-update", () => deps.checkUpdate()) - ipcMain.handle("install-update", () => deps.installUpdate()) - ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) - ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + on("loading-window-complete", () => deps.loadingWindowComplete()) + handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) + handle("check-update", () => deps.checkUpdate()) + handle("install-update", () => deps.installUpdate()) + handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) debugStore("get", name, key, { @@ -165,27 +233,27 @@ export function registerIpcHandlers(deps: Deps) { 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) => { + 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) => { + 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) => { + handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() }) - ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store) }) - ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store).length }) - ipcMain.handle( + handle( "open-directory-picker", async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ @@ -198,7 +266,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle( + handle( "open-file-picker", async ( _event: IpcMainInvokeEvent, @@ -215,7 +283,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle( + handle( "save-file-picker", async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { const result = await dialog.showSaveDialog({ @@ -227,11 +295,11 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { + on("open-link", (_event: IpcMainEvent, url: string) => { void shell.openExternal(url) }) - ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { if (!app) return shell.openPath(path) await new Promise((resolve, reject) => { const [cmd, args] = @@ -240,7 +308,7 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("read-clipboard-image", () => { + handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null const buffer = image.toPNG().buffer @@ -248,34 +316,34 @@ export function registerIpcHandlers(deps: Deps) { return { buffer, width: size.width, height: size.height } }) - ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { + on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { new Notification({ title, body }).show() }) - ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length) + handle("get-window-count", () => BrowserWindow.getAllWindows().length) - ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { + handle("get-window-focused", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false }) - ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { + handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() }) - ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { + handle("show-window", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.show() }) - ipcMain.on("relaunch", () => { + on("relaunch", () => { deps.relaunch() }) - ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) - ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) + handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return setTitlebar(win, theme) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index e8362070d0a5..fa476ea0ec58 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -5,7 +5,7 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" -import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" +import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -228,10 +228,6 @@ function prepareServerEnv(password: string) { Object.assign(process.env, env) } -function shellEscape(value: string) { - return `'${value.replace(/'/g, `'"'"'`)}'` -} - function forwardLines( stream: NodeJS.ReadableStream, source: WslCommandLine["stream"], diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 9c9dc79e89f1..4a95a28fb48b 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -28,6 +28,7 @@ import { probeWslRuntime, readWslCommandVersion, resolveWslOpencode, + summarize, upgradeWslOpencode, wslNeedsRestart, } from "./wsl" @@ -53,7 +54,6 @@ export function wslServerIdForDistro(distro: string) { } export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { - const mainLogger: ControllerLogger | undefined = logger let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() @@ -151,6 +151,17 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion)) } + const refreshDistroLists = async (opts: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void }) => { + const [installedResult, onlineResult] = await Promise.allSettled([ + listInstalledWslDistros(opts), + listOnlineWslDistros(opts), + ]) + return { + installed: installedResult.status === "fulfilled" ? installedResult.value : [], + online: onlineResult.status === "fulfilled" ? onlineResult.value : [], + } + } + const nextStartAttempt = (id: string) => { const next = (startAttempts.get(id) ?? 0) + 1 startAttempts.set(id, next) @@ -172,7 +183,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa await stopServerInternal(id) if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "starting" }) - mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro }) + logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { const sidecar = await spawnSidecar(item.config.distro) if (!isCurrentStartAttempt(id, attempt)) { @@ -195,26 +206,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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 }) + 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) - mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) + logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) }) - mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) + 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 if (isMissingDistroError(message)) { removeMissingServer(id) - mainLogger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message }) + logger?.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 // nothing surfaces unless the user opens the WSL servers dialog. - mainLogger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) } } @@ -274,13 +285,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa async refreshDistros() { await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { appendTranscript({ stream: "system", text: "Listing WSL distros" }) - const [installedResult, onlineResult] = await Promise.allSettled([ - listInstalledWslDistros({ signal: abort.signal, onLine }), - listOnlineWslDistros({ signal: abort.signal, onLine }), - ]) - const installed = installedResult.status === "fulfilled" ? installedResult.value : [] - const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] - setState({ installed, online }) + setState(await refreshDistroLists({ signal: abort.signal, onLine })) }) }, @@ -309,16 +314,10 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` throw new Error(message) } - const [installedResult, onlineResult] = await Promise.allSettled([ - listInstalledWslDistros({ signal: abort.signal, onLine }), - listOnlineWslDistros({ signal: abort.signal, onLine }), - ]) - const installed = installedResult.status === "fulfilled" ? installedResult.value : [] - const online = onlineResult.status === "fulfilled" ? onlineResult.value : [] + const distros = await refreshDistroLists({ signal: abort.signal, onLine }) const probe = await probeWslDistro(name, { signal: abort.signal, onLine }) setState({ - installed, - online, + ...distros, distroProbes: { ...state.distroProbes, [name]: probe }, }) }) @@ -534,14 +533,6 @@ function opencodeCheck( } } -function summarize(value: string) { - return value - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - .join("\n") -} - function isMissingDistroError(message: string) { return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message) } diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index abfc8a9ca6b0..b10084e63247 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -186,12 +186,6 @@ export type WslRegistryDistro = { version: number } -// Distros that are designed to run as root and don't have a user-level -// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that -// prompts for a UNIX username on first invocation; if that never runs, -// wsl.exe -d hangs silently forever. -const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"]) - // Read LXSS metadata from the Windows registry. This never invokes // wsl.exe, so it is safe to call when wsl.exe itself is wedged. // DefaultUid === 0 on a user-oriented distro means the first-run @@ -243,20 +237,6 @@ export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise< return out } -export type WslFirstRunCheck = - | { status: "ok" } - | { status: "needs-first-run"; defaultUid: number } - | { status: "not-installed" } - -export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise { - const distros = await readWslDistrosFromRegistry(opts) - const entry = distros.find((d) => d.name === distro) - if (!entry) return { status: "not-installed" } - if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" } - if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid } - return { status: "ok" } -} - export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { return runWslInDistro(["sh", "-lc", script], distro, opts) } @@ -307,10 +287,6 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) { return parseOnlineDistros(result.stdout) } -export async function installWslRuntime(opts?: RunWslOptions) { - return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) -} - export async function installWslRuntimeElevated(opts?: RunWslOptions) { const script = [ "$ErrorActionPreference = 'Stop'", @@ -510,7 +486,7 @@ function firstLine(value: string) { ) } -function summarize(value: string) { +export function summarize(value: string) { return value .split(/\r?\n/g) .map((line) => line.trim()) @@ -518,7 +494,7 @@ function summarize(value: string) { .join("\n") } -function shellEscape(value: string) { +export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 507c4d68377f..6aa2d148be6b 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -17,7 +17,11 @@ const api: ElectronAPI = { subscribe: (cb) => { const handler = (_: unknown, event: WslServersEvent) => cb(event) ipcRenderer.on("wsl-servers-event", handler) - return () => ipcRenderer.removeListener("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"), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ea4c0a811bea..e8d2b5a1daf0 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -14,8 +14,6 @@ export type ServerReadyData = { export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } -export type WslServerStep = "wsl" | "distro" | "opencode" - export type WslRuntimeCheck = { available: boolean version: string | null diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 2088dbcd1053..9265b858fbdd 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -180,13 +180,14 @@ const createPlatform = (): Platform => { }) } - const handleWslPicker = async (result: T | null): Promise => { + const handleWslPicker = async (result: T): Promise => { const distro = activeWslDistro() if (!result || !distro) return result + const convert = (path: string) => window.api.wslPath(path, "linux", distro).catch(() => path) if (Array.isArray(result)) { - return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any + return (await Promise.all(result.map(convert))) as T } - return window.api.wslPath(result, "linux", distro).catch(() => result) as any + return (await convert(result)) as T } const storage = (() => { diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index d6ed08644505..523b12e72f57 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -67,18 +67,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu #[tauri::command] #[specta::specta] -pub fn get_wsl_config(_app: AppHandle) -> Result { - // let store = app - // .store(SETTINGS_STORE) - // .map_err(|e| format!("Failed to open settings store: {}", e))?; - - // let enabled = store - // .get(WSL_ENABLED_KEY) - // .as_ref() - // .and_then(|v| v.as_bool()) - // .unwrap_or(false); - - Ok(WslConfig { enabled: false }) +pub fn get_wsl_config(app: AppHandle) -> Result { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let enabled = store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(WslConfig { enabled }) } #[tauri::command] diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 289953b09349..25d67628694f 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -76,14 +76,15 @@ const createPlatform = (): Platform => { return commands.wslPath("~", "windows").catch(() => undefined) } - const handleWslPicker = async (result: T | null): Promise => { + const handleWslPicker = async (result: T): Promise => { if (!result) return result const wsl = await commands.getWslConfig().catch(() => null) if (!wsl?.enabled) return result + const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path) if (Array.isArray(result)) { - return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any + return (await Promise.all(result.map(convert))) as T } - return commands.wslPath(result, "linux").catch(() => result) as any + return (await convert(result)) as T } return { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 39003f3b6825..a5ca4a5aaab2 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,7 +1,6 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js" +import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" import { useI18n } from "../context/i18n" -import { DialogContext } from "../context/dialog" import { IconButton } from "./icon-button" export interface DialogProps extends ParentProps { @@ -20,12 +19,6 @@ export interface DialogProps extends ParentProps { export function Dialog(props: DialogProps) { const i18n = useI18n() - const dialogCtx = useContext(DialogContext) - createEffect(() => { - if (!dialogCtx) return - if (props.dismissOutside === undefined) return - dialogCtx.active?.setDismissOutside(props.dismissOutside) - }) return (
{ + if (props.dismissOutside === false) e.preventDefault() + }} >
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index a39f5a0f3fc5..03045ee4022c 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -23,14 +23,10 @@ type Active = { owner: Owner onClose?: () => void setClosing: (closing: boolean) => void - dismissOutside: () => boolean - setDismissOutside: (value: boolean) => void } const Context = createContext>() -export const DialogContext = Context - function init() { const [active, setActive] = createSignal() const timer = { current: undefined as ReturnType | undefined } @@ -93,17 +89,12 @@ function init() { const id = Math.random().toString(36).slice(2) let dispose: (() => void) | undefined let setClosing: ((closing: boolean) => void) | undefined - let setDismissOutsideSignal: ((value: boolean) => void) | undefined - let dismissOutsideAccessor: (() => boolean) | undefined const node = runWithOwner(owner, () => createRoot((d: () => void) => { dispose = d const [closing, setClosingSignal] = createSignal(false) setClosing = setClosingSignal - const [dismissOutside, setDismissOutside] = createSignal(true) - dismissOutsideAccessor = dismissOutside - setDismissOutsideSignal = setDismissOutside return ( - { - if (dismissOutside()) close() - }} - /> + {element()} @@ -127,7 +113,7 @@ function init() { }), ) - if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return + if (!dispose || !setClosing) return setActive({ id, @@ -136,8 +122,6 @@ function init() { owner, onClose, setClosing, - dismissOutside: dismissOutsideAccessor, - setDismissOutside: setDismissOutsideSignal, }) } @@ -182,8 +166,5 @@ export function useDialog() { close() { ctx.close() }, - setDismissOutside(value: boolean) { - ctx.active?.setDismissOutside(value) - }, } } From 81e907febecc0eeab492f83bfa0fed35f002387f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:02:30 +1000 Subject: [PATCH 71/88] . --- .../src/components/dialog-select-server.tsx | 65 +------------------ .../app/src/components/dialog-wsl-server.tsx | 41 +++++------- packages/desktop-electron/src/main/index.ts | 8 --- .../desktop-electron/src/main/wsl-servers.ts | 6 +- .../desktop-electron/src/renderer/index.tsx | 6 +- packages/desktop/src/index.tsx | 6 +- packages/ui/src/context/dialog.tsx | 34 +++++----- 7 files changed, 40 insertions(+), 126 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 8708fb8c84e4..e8eed170c981 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -59,7 +59,6 @@ interface ServerFormProps { placeholder: string busy: boolean error: string - status: boolean | undefined onChange: (value: string) => void onNameChange: (value: string) => void onUsernameChange: (value: string) => void @@ -80,38 +79,6 @@ function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Side return conn.type === "sidecar" && conn.variant === "wsl" } -function useServerPreview() { - const checkServerHealth = useCheckServerHealth() - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async ( - value: string, - username: string, - password: string, - setStatus: (value: boolean | undefined) => void, - ) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const http: ServerConnection.HttpBase = { url: normalized } - if (username) http.username = username - if (password) http.password = password - const result = await checkServerHealth(http) - setStatus(result.healthy) - } - - return { previewStatus } -} - function ServerForm(props: ServerFormProps) { const language = useLanguage() const keyDown = (event: KeyboardEvent) => { @@ -184,7 +151,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const language = useLanguage() const { defaultKey, canDefault, setDefault } = useDefaultServer() const wslServers = useWslServers() - const { previewStatus } = useServerPreview() const checkServerHealth = useCheckServerHealth() let disposed = false onCleanup(() => { @@ -199,7 +165,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { password: "", error: "", showForm: false, - status: undefined as boolean | undefined, }, addWsl: { showWizard: props.initialView === "add-wsl", @@ -212,7 +177,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { username: "", password: "", error: "", - status: undefined as boolean | undefined, }, }) @@ -224,7 +188,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { password: "", error: "", showForm: false, - status: undefined, }) } const resetEdit = () => { @@ -235,7 +198,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { username: "", password: "", error: "", - status: undefined, }) } @@ -417,10 +379,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { return wslState()?.opencodeChecks[conn.distro] ?? null } - const displayVersion = (conn: ServerConnection.Any) => { - return wslCheck(conn)?.version ?? undefined - } - async function select(conn: ServerConnection.Any, persist?: boolean) { if (!isSelectable(conn)) return if (!persist && health(ServerConnection.key(conn))?.healthy === false) return @@ -470,9 +428,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleAddChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) - void previewStatus(value, store.addServer.username, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) } const handleAddNameChange = (value: string) => { @@ -483,25 +438,16 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleAddUsernameChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { username: value, error: "" }) - void previewStatus(store.addServer.url, value, store.addServer.password, (next) => - setStore("addServer", { status: next }), - ) } const handleAddPasswordChange = (value: string) => { if (addMutation.isPending) return setStore("addServer", { password: value, error: "" }) - void previewStatus(store.addServer.url, store.addServer.username, value, (next) => - setStore("addServer", { status: next }), - ) } const handleEditChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { value, error: "" }) - void previewStatus(value, store.editServer.username, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) } const handleEditNameChange = (value: string) => { @@ -512,17 +458,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleEditUsernameChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { username: value, error: "" }) - void previewStatus(store.editServer.value, value, store.editServer.password, (next) => - setStore("editServer", { status: next }), - ) } const handleEditPasswordChange = (value: string) => { if (editMutation.isPending) return setStore("editServer", { password: value, error: "" }) - void previewStatus(store.editServer.value, store.editServer.username, value, (next) => - setStore("editServer", { status: next }), - ) } const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => { @@ -554,7 +494,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { username: DEFAULT_USERNAME, password: "", error: "", - status: undefined, }) } @@ -568,7 +507,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { username: conn.http.username ?? "", password: conn.http.password ?? "", error: "", - status: health(ServerConnection.key(conn))?.healthy, }) } @@ -674,7 +612,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { placeholder={language.t("dialog.server.add.placeholder")} busy={formBusy()} error={isAddMode() ? store.addServer.error : store.editServer.error} - status={isAddMode() ? store.addServer.status : store.editServer.status} onChange={isAddMode() ? handleAddChange : handleEditChange} onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange} onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange} @@ -735,7 +672,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { conn={i} dimmed={blocked()} status={health(key)} - version={displayVersion(i)} + version={wslCheck(i)?.version ?? undefined} class="flex items-center gap-3 min-w-0 flex-1" badge={ diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index ddc68cf9990b..8f799554038a 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -51,14 +51,13 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { disposed = true }) const busy = createMemo(() => !!current()?.job || store.adding) - const selectedDistro = () => store.selectedDistro const selectedProbe = createMemo(() => { - const distro = selectedDistro() + const distro = store.selectedDistro if (!distro) return null return current()?.distroProbes[distro] ?? null }) const selectedInstalled = createMemo(() => { - const distro = selectedDistro() + const distro = store.selectedDistro if (!distro) return null return (current()?.installed ?? []).find((item) => item.name === distro) ?? null }) @@ -68,7 +67,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name))) const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null) const opencodeCheck = createMemo(() => { - const distro = selectedDistro() + const distro = store.selectedDistro if (!distro) return null return current()?.opencodeChecks[distro] ?? null }) @@ -80,7 +79,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { }) const distroUnavailableMessage = createMemo(() => { const probe = distroWarningProbe() - const distro = selectedDistro() + const distro = store.selectedDistro if (!probe || probe.canExecute || !distro) return null if (!selectedInstalled()) return `${distro} is not installed yet.` return `Open ${distro} once to finish setup.` @@ -91,10 +90,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { if (probe.hasBash && probe.hasCurl) return null return probe }) - const opencodeMismatchCheck = createMemo(() => { - const check = opencodeCheck() - return check?.matchesDesktop === false ? check : null - }) const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro))) const addableInstalledDistros = createMemo(() => { return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name)) @@ -121,7 +116,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() - if (!probe || !selectedDistro()) return false + if (!probe || !store.selectedDistro) return false if (selectedInstalled()?.version === 1) return false return probe.canExecute && probe.hasBash && probe.hasCurl }) @@ -185,7 +180,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { if (!state.installed.length && !state.online.length) { return { key: "distros", run: () => refreshDistrosMutation.mutateAsync() } } - const distro = selectedDistro() + const distro = store.selectedDistro if (distro && !state.distroProbes[distro]) { return { key: `probe-distro:${distro}`, run: () => probeDistroMutation.mutateAsync(distro) } } @@ -220,7 +215,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const state = current() const distro = defaultInstalledDistro() if (!state || !distro || busy()) return - if (selectedDistro()) return + if (store.selectedDistro) return if (existingServerDistros().has(distro.name)) return setStore("selectedDistro", distro.name) }) @@ -246,7 +241,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const distroMessage = createMemo(() => { const state = current() if (!state) return "Checking distros..." - const distro = selectedDistro() + const distro = store.selectedDistro if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...` if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...` if (state.job?.kind === "distros") return "Listing distros..." @@ -259,7 +254,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const opencodeMessage = createMemo(() => { const state = current() if (!state) return "Checking OpenCode..." - const distro = selectedDistro() + const distro = store.selectedDistro if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") { return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..." } @@ -292,7 +287,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { } const runSelectedDistro = (action: (distro: string) => Promise) => { - const distro = selectedDistro() + const distro = store.selectedDistro if (!distro) return void run(() => action(distro)) } @@ -303,7 +298,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { } const finish = async () => { - const distro = selectedDistro() + const distro = store.selectedDistro if (!distro) return setStore("adding", true) try { @@ -402,7 +397,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
Choose a distro
- +
{opencodeMessage()}
- + {(check) => (
Path: {check().resolvedPath ?? "not found"}
@@ -652,7 +647,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
- +
@@ -402,7 +331,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="small" disabled={busy()} - onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))} + onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))} > Refresh @@ -446,11 +375,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
Install
- - - {installDistroPercent()}% - - @@ -458,7 +382,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="small" disabled={busy() || !installTarget()} - onClick={() => void run(() => installDistroMutation.mutateAsync(installTarget()!.name))} + onClick={() => void run(() => api.installDistro(installTarget()!.name))} > {installingDistro() ? "Installing..." : "Install"} @@ -531,7 +455,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy() || !selectedInstalled()} - onClick={() => runSelectedDistro((distro) => openTerminalMutation.mutateAsync(distro))} + onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))} > Open terminal @@ -539,7 +463,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy() || !store.selectedDistro} - onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))} + onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))} > Refresh @@ -568,7 +492,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="ghost" size="large" disabled={busy()} - onClick={() => runSelectedDistro((distro) => probeOpencodeMutation.mutateAsync(distro))} + onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))} > Refresh @@ -578,7 +502,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy()} - onClick={() => runSelectedDistro((distro) => installOpencodeMutation.mutateAsync(distro))} + onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))} > {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} @@ -606,47 +530,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { - - {(progress) => ( -
-
- -
Progress
-
-
{progress().title}
-
- - {(line) => ( -
- {line.text} -
- )} -
-
-
- )} -
- - 0}> -
-
Diagnostics
-
- {(line) =>
{line.text}
}
-
-
-
-
-
From fcb4bc1b3a8fc12a5ed62a627e0c449bb3fbee8e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 11:20:57 +1000 Subject: [PATCH 80/88] deslop --- bun.lock | 4 +- nix/hashes.json | 8 +- package.json | 2 +- packages/app/src/app.tsx | 135 ++- .../src/components/dialog-select-server.tsx | 45 +- packages/app/src/components/prompt-input.tsx | 6 +- .../src/components/session/session-header.tsx | 33 +- .../src/components/status-popover-body.tsx | 90 +- packages/app/src/components/terminal.tsx | 136 +-- packages/app/src/context/global-sdk.tsx | 19 +- .../src/context/global-sync/child-store.ts | 9 +- packages/app/src/context/platform.tsx | 6 + packages/app/src/context/prompt.tsx | 9 +- packages/app/src/context/server.tsx | 92 +- packages/app/src/context/terminal.test.ts | 2 +- packages/app/src/context/terminal.tsx | 120 +- packages/app/src/index.ts | 1 - packages/app/src/pages/layout.tsx | 48 +- .../app/src/pages/layout/sidebar-project.tsx | 13 +- .../app/src/pages/layout/sidebar-shell.tsx | 8 +- .../src/pages/layout/sidebar-workspace.tsx | 31 +- packages/app/src/utils/scoped-cache.test.ts | 5 +- packages/app/src/utils/scoped-cache.ts | 17 +- packages/app/src/utils/server-health.ts | 5 +- .../desktop-electron/electron.vite.config.ts | 7 - .../desktop-electron/src/main/constants.ts | 1 - packages/desktop-electron/src/main/index.ts | 138 +-- packages/desktop-electron/src/main/migrate.ts | 3 +- packages/desktop-electron/src/main/server.ts | 11 +- packages/desktop-electron/src/main/store.ts | 10 +- packages/desktop-electron/src/main/windows.ts | 4 +- .../desktop-electron/src/main/wsl-servers.ts | 21 +- .../desktop-electron/src/renderer/index.tsx | 92 +- packages/desktop/src-tauri/src/server.rs | 39 +- packages/desktop/src/index.tsx | 33 +- packages/opencode/script/build.ts | 1 + packages/opencode/src/cli/cmd/agent.ts | 296 +++-- packages/opencode/src/cli/cmd/github.ts | 351 +++--- packages/opencode/src/cli/cmd/mcp.ts | 1044 ++++++++--------- packages/opencode/src/cli/cmd/providers.ts | 462 ++++---- packages/opencode/src/cli/cmd/stats.ts | 234 ++-- packages/opencode/src/cli/effect-cmd.ts | 27 +- .../instance/httpapi/handlers/session.ts | 6 +- packages/opencode/src/v2/session.ts | 19 +- .../test/cli/effect-cmd-instance-als.test.ts | 48 + .../test/server/httpapi-parity.test.ts | 128 ++ packages/ui/src/components/dialog.css | 5 +- packages/ui/src/context/dialog.tsx | 45 +- 48 files changed, 1764 insertions(+), 2105 deletions(-) create mode 100644 packages/opencode/test/cli/effect-cmd-instance-als.test.ts create mode 100644 packages/opencode/test/server/httpapi-parity.test.ts diff --git a/bun.lock b/bun.lock index 12677ea97632..25068f3d9a56 100644 --- a/bun.lock +++ b/bun.lock @@ -715,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -3078,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/nix/hashes.json b/nix/hashes.json index bea97a0cb338..84c3b13043f5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", - "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", - "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", - "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } diff --git a/package.json b/package.json index b15fbb254418..de3dd31f4034 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 4f175386129d..2649260cf3e3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,8 +1,6 @@ import "@/index.css" -import { Button } from "@opencode-ai/ui/button" import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { MarkedProvider } from "@opencode-ai/ui/context/marked" @@ -13,9 +11,12 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { Effect } from "effect" import { type Component, createMemo, + createResource, + createSignal, ErrorBoundary, For, type JSX, @@ -23,7 +24,6 @@ import { onCleanup, type ParentProps, Show, - startTransition, Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -38,7 +38,6 @@ import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" -import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" @@ -47,6 +46,7 @@ import { WslServersProvider } from "@/context/wsl-servers" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" +import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) const loadSession = () => import("@/pages/session") @@ -75,6 +75,7 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] + wsl?: boolean activeServer?: string } api?: { @@ -175,48 +176,80 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() - const healthy = createMemo(() => props.disableHealthCheck || server.healthy()) + const checkServerHealth = useCheckServerHealth() - const splash = ( -
- -
+ const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + + // performs repeated health check with a grace period for + // non-http connections, otherwise fails instantly + const [startupHealthCheck, healthCheckActions] = createResource(() => + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), ) return ( - - - - { - startTransition(() => { - server.setActive(key) - }) - }} - /> - } - > - {props.children} - - - - + + +
+ } + > + {/* + +
+ } + >*/} + {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + {/**/} + ) } -function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) => void }) { - const dialog = useDialog() +function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { const language = useLanguage() - const platform = usePlatform() const server = useServer() const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) const name = createMemo(() => server.name || server.key) const serverToken = "\u0000server\u0000" const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) - const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") + + const timer = setInterval(() => props.onRetry?.(), 1000) + onCleanup(() => clearInterval(timer)) return (
@@ -228,34 +261,6 @@ function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) {unreachable()[1]}

{language.t("app.server.retrying")}

- - -
0}>
@@ -295,21 +300,13 @@ export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key servers?: Array - serversReady?: boolean router?: Component disableHealthCheck?: boolean }) { - // ServerKey wraps the whole Router so that switching `server.key` throws - // away any session / pty state from the previous server. Preserving the - // route across servers doesn't work because session ids, pty ids, and - // most URL-addressable resources are server-scoped — you'd 404 on every - // fetch. The click handler that swaps servers also navigates back to "/" - // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL. return ( diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ac0710c3392b..909d45ae710d 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -75,6 +75,32 @@ function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Side return conn.type === "sidecar" && conn.variant === "wsl" } +function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const [defaultKey, defaultActions] = createResource( + async () => { + try { + return (await platform.getDefaultServer?.()) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) + const setDefault = async (key: ServerConnection.Key | null) => { + try { + await platform.setDefaultServer?.(key) + defaultActions.mutate(key) + } catch (err) { + showRequestError(language, err) + } + } + return { defaultKey, canDefault, setDefault } +} + function ServerForm(props: ServerFormProps) { const language = useLanguage() const keyDown = (event: KeyboardEvent) => { @@ -146,6 +172,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const platform = usePlatform() const language = useLanguage() const wslServers = useWslServers() + const defaultServer = useDefaultServer() const checkServerHealth = useCheckServerHealth() let disposed = false onCleanup(() => { @@ -271,7 +298,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, onSuccess: async (key) => { server.remove(key) - if (server.defaultKey() === key) await server.setDefault(null) + if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) }, onError: (err) => showRequestError(language, err), })) @@ -546,7 +573,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function handleRemove(key: ServerConnection.Key) { server.remove(key) - if (server.defaultKey() === key) await server.setDefault(null) + if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) } return ( @@ -603,7 +630,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const wsl = isWslSidecar(i) const wslDistro = wsl ? i.distro : undefined const blocked = () => health(key)?.healthy === false - const canChangeDefault = () => server.canDefault() && i.type !== "ssh" + const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh" const canRemove = () => i.type === "http" || wsl const outdated = () => { const check = wslCheck(i) @@ -632,7 +659,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { version={wslCheck(i)?.version ?? undefined} class="flex items-center gap-3 min-w-0 flex-1" badge={ - + {language.t("dialog.server.status.default")} @@ -689,15 +716,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { Retry start - - void server.setDefault(key)}> + + void defaultServer.setDefault(key)}> {language.t("dialog.server.menu.default")} - - void server.setDefault(null)}> + + void defaultServer.setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a85e160d10ed..0a18096164f0 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1253,11 +1253,7 @@ export const PromptInput: Component = (props) => { } const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - loadAgentsQuery(sdk.directory), - loadProvidersQuery(null), - loadProvidersQuery(sdk.directory), - ], + queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)], })) const agentsLoading = () => agentsQuery.isLoading diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 5442d9985def..3d4f58deec44 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,10 +6,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" -import { StatusPopover } from "../status-popover" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" -import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -25,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" +import { StatusPopover } from "../status-popover" const OPEN_APPS = [ "vscode", @@ -129,13 +129,6 @@ const showRequestError = (language: ReturnType, err: unknown }) } -function titlebarMounts() { - return { - center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined, - right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined, - } -} - export function SessionHeader() { const layout = useLayout() const command = useCommand() @@ -226,7 +219,6 @@ export function SessionHeader() { const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, }) - const [mounts, setMounts] = createStore(titlebarMounts()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo( @@ -240,19 +232,6 @@ export function SessionHeader() { messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) - const syncMounts = () => { - const next = titlebarMounts() - if (mounts.center === next.center && mounts.right === next.right) return - setMounts(next) - } - - onMount(() => { - syncMounts() - const observer = new MutationObserver(() => syncMounts()) - observer.observe(document.body, { childList: true, subtree: true }) - onCleanup(() => observer.disconnect()) - }) - const selectApp = (app: OpenApp) => { if (!options().some((item) => item.id === app)) return setPrefs("app", app) @@ -290,8 +269,12 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const centerMount = createMemo(() => mounts.center) - const rightMount = createMemo(() => mounts.right) + const [centerMount, setCenterMount] = createSignal(null) + const [rightMount, setRightMount] = createSignal(null) + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + setRightMount(document.getElementById("opencode-titlebar-right")) + }) return ( <> diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index fcc42f04f28f..952e3eac64a0 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,15 +6,16 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition } from "solid-js" +import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" -import { loadMcpQuery } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { ServerConnection, useServer } from "@/context/server" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { loadMcpQuery } from "@/context/global-sync" const pollMs = 10_000 @@ -36,7 +37,7 @@ const listServersByHealth = ( status: Record, ) => { if (!list.length) return list - const order = new Map(list.map((conn, index) => [conn, index] as const)) + const order = new Map(list.map((url, index) => [url, index] as const)) const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 @@ -65,7 +66,7 @@ const useServerHealth = (servers: Accessor, enabled: Acc let dead = false const refresh = async () => { - const results: Record = {} + const results: Record = {} await Promise.all( list.map(async (conn) => { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) @@ -86,6 +87,53 @@ const useServerHealth = (servers: Accessor, enabled: Acc return status } +const useDefaultServerKey = ( + get: (() => string | Promise | null | undefined) | undefined, +) => { + const [state, setState] = createStore({ + url: undefined as string | undefined, + tick: 0, + }) + + createEffect(() => { + state.tick + let dead = false + const result = get?.() + if (!result) { + setState("url", undefined) + onCleanup(() => { + dead = true + }) + return + } + + if (result instanceof Promise) { + void result.then((next) => { + if (dead) return + setState("url", next ? normalizeServerUrl(next) : undefined) + }) + onCleanup(() => { + dead = true + }) + return + } + + setState("url", normalizeServerUrl(result)) + onCleanup(() => { + dead = true + }) + }) + + return { + key: () => { + const u = state.url + if (!u) return + return ServerConnection.key({ type: "http", http: { url: u } }) + }, + refresh: () => setState("tick", (value) => value + 1), + } +} + const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() @@ -111,10 +159,23 @@ const useMcpToggleMutation = () => { export function StatusPopoverBody(props: { shown: Accessor }) { const sync = useSync() const server = useServer() + const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const fail = (err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } + + createEffect(() => { + if (!props.shown()) return + }) + let dialogRun = 0 let dialogDead = false onCleanup(() => { @@ -131,6 +192,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const health = useServerHealth(servers, props.shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() + const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) @@ -189,18 +251,8 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - startTransition(() => { - batch(() => { - if (server.key !== key) { - if (typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } - } else { - navigate("/") - } - server.setActive(key) - }) - }) + navigate("/") + queueMicrotask(() => server.setActive(key)) }} > @@ -212,7 +264,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - + {language.t("common.default")} @@ -236,7 +288,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const run = ++dialogRun void import("./dialog-select-server").then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => navigate("/")} />) + dialog.show(() => , defaultServer.refresh) }) }} > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 12e1ba5e3fe2..ff5ff9dada89 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -62,26 +62,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { }, } -const getTerminalColors = (theme: ReturnType): TerminalColors => { - const mode = theme.mode() === "dark" ? "dark" : "light" - const fallback = DEFAULT_TERMINAL_COLORS[mode] - const currentTheme = theme.themes()[theme.themeId()] - if (!currentTheme) return fallback - const variant = mode === "dark" ? currentTheme.dark : currentTheme.light - if (!variant?.seeds && !variant?.palette) return fallback - const resolved = resolveThemeVariant(variant, mode === "dark") - const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background - const alpha = mode === "dark" ? 0.25 : 0.2 - const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) - return { - background, - foreground: text, - cursor: text, - selectionBackground: withAlpha(base, alpha), - } -} - const debugTerminal = (...values: unknown[]) => { if (!import.meta.env.DEV) return console.debug("[terminal]", ...values) @@ -94,11 +74,6 @@ 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 @@ -258,7 +233,28 @@ export const Terminal = (props: TerminalProps) => { }) } - const terminalColors = createMemo(() => getTerminalColors(theme)) + const getTerminalColors = (): TerminalColors => { + const mode = theme.mode() === "dark" ? "dark" : "light" + const fallback = DEFAULT_TERMINAL_COLORS[mode] + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return fallback + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds && !variant?.palette) return fallback + const resolved = resolveThemeVariant(variant, mode === "dark") + const text = resolved["text-stronger"] ?? fallback.foreground + const background = resolved["background-stronger"] ?? fallback.background + const alpha = mode === "dark" ? 0.25 : 0.2 + const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) + const selectionBackground = withAlpha(base, alpha) + return { + background, + foreground: text, + cursor: text, + selectionBackground, + } + } + + const terminalColors = createMemo(getTerminalColors) const scheduleFit = () => { if (disposed) return @@ -454,32 +450,20 @@ export const Terminal = (props: TerminalProps) => { output.flush(resolve) }) - // 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, - }) + if (restore && restoreSize) { 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 } @@ -536,16 +520,6 @@ export const Terminal = (props: TerminalProps) => { next.password = password } - logTerminal("socket.open", { - id, - serverKey: server.key ?? null, - directory, - restoreLength: restore.length, - sdkUrl: sdk.url, - currentUrl: url, - wsUrl: next.toString(), - }) - const socket = new WebSocket(next) socket.binaryType = "arraybuffer" ws = socket @@ -553,16 +527,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { if (disposed) return tries = 0 - logTerminal("socket.connected", { - id, - serverKey: server.key ?? null, - directory, - currentUrl: url, - }) - // 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) } @@ -617,14 +581,6 @@ 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: url, - }) retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } @@ -650,13 +606,6 @@ 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) @@ -664,30 +613,17 @@ export const Terminal = (props: TerminalProps) => { drop?.() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) - // Defer finalize (persistTerminal + local cleanup()) to a microtask so - // that its synchronous store write inside `persistTerminal` — which - // flows through `props.onCleanup` -> `ops.update` -> `update()` in - // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does - // NOT run inside the outer solid cleanNode cascade. Running it - // synchronously mid-cascade races with solid's recursive owned - // iteration (readSignal on a stale memo re-enters updateComputation, - // which nulls an ancestor's owned while the outer loop is still - // iterating it) and crashes with "Cannot read properties of null - // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode. - // queueMicrotask runs after the current sync reactive flush, so the - // store write lands in a fresh tick. const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) cleanup() } - const schedule = () => queueMicrotask(finalize) if (!output) { - schedule() + finalize() return } - output.flush(schedule) + output.flush(finalize) }) return ( diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 5666442a4d2e..e53d60d5a0ea 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -95,15 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo buffer.length = 0 } - const clearPending = () => { - if (timer) clearTimeout(timer) - timer = undefined - queue.length = 0 - buffer.length = 0 - coalesced.clear() - staleDeltas.clear() - } - const schedule = () => { if (timer) return const elapsed = Date.now() - last @@ -211,10 +202,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })().finally(() => { run = undefined - if (abort.signal.aborted || !started) { - clearPending() - return - } flush() }) return run @@ -238,7 +225,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo onCleanup(() => { stop() abort.abort() - clearPending() + flush() }) const sdk = createSdkForServer({ @@ -248,9 +235,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }) return { - get url() { - return server.current?.http.url ?? currentServer.http.url - }, + url: currentServer.http.url, client: sdk, event: { on: emitter.on.bind(emitter), diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index db0c538104ee..0138310cdccd 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -112,15 +112,8 @@ export function createChildStoreManager(input: { lifecycle.delete(key) const dispose = disposers.get(key) if (dispose) { + dispose() disposers.delete(key) - // Defer the actual solid-js root disposal. When disposeDirectory runs - // from pinForOwner's onCleanup during a parent remount, calling - // dispose() here triggers a nested cleanNode cascade on the inner - // root while the outer cascade is mid-traversal, which corrupts - // solid-js's graph walk state and throws `Cannot read properties of - // null (reading '1')` at chunk-*.js:992. Running dispose on a - // microtask lets the outer cleanup finish first. - queueMicrotask(dispose) } delete children[key] input.onDispose(key) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 928ed5ee2e7f..757c8e49a52e 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -147,6 +147,12 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void + /** Get the configured WSL integration (desktop only) */ + getWslEnabled?(): Promise + + /** Set the configured WSL integration (desktop only) */ + setWslEnabled?(config: boolean): Promise | void + /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 5a33529b4e71..dffb7983104b 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -232,13 +232,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - // Defer the dispose calls to a microtask; synchronous nested dispose - // inside a parent onCleanup corrupts solid-js's in-flight cleanNode - // traversal during mass remounts (see context/terminal.tsx for the - // same pattern). - const pending = Array.from(cache.values(), (entry) => entry.dispose) + for (const entry of cache.values()) { + entry.dispose() + } cache.clear() - if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) } onCleanup(disposeAll) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 2bc89518375c..636c566a0ae8 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,11 +1,8 @@ -import { showToast } from "@opencode-ai/ui/toast" import { createSimpleContext } from "@opencode-ai/ui/context" -import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { useCheckServerHealth } from "@/utils/server-health" -import { useLanguage } from "./language" -import { usePlatform } from "./platform" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http @@ -26,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals function projectsKey(key: ServerConnection.Key) { if (!key) return "" - if (key === "sidecar" || key === "local:windows") return "local" + if (key === "sidecar") return "local" if (isLocalHost(key)) return "local" return key } @@ -84,7 +81,7 @@ export namespace ServerConnection { return Key.make(conn.http.url) case "sidecar": { if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) - return Key.make("local:windows") + return Key.make("sidecar") } case "ssh": return Key.make(`ssh:${conn.host}`) @@ -100,13 +97,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( init: (props: { defaultServer: ServerConnection.Key disableHealthCheck?: boolean - serversReady?: boolean servers?: Array }) => { const checkServerHealth = useCheckServerHealth() - const language = useLanguage() - const platform = usePlatform() - const serversReady = () => props.serversReady ?? true const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), @@ -144,7 +137,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const [state, setState] = createStore({ active: props.defaultServer, - default: props.defaultServer as ServerConnection.Key | null, healthy: undefined as boolean | undefined, }) @@ -179,28 +171,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (state.active !== input) setState("active", input) } - async function setDefault(input: ServerConnection.Key | null) { - if (!platform.setDefaultServer) return input - try { - await platform.setDefaultServer(input) - setState("default", input) - return input - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - throw err - } - } - - 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 - } + createEffect(() => { + if (typeof window === "undefined") return + window.__OPENCODE__ ??= {} + window.__OPENCODE__.activeServer = state.active + }) function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) @@ -223,39 +198,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( batch(() => { setStore("list", list) if (state.active === key) { - setState("active", nextActiveKey(key)) + const next = list[0] + setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer) } }) } - const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) - - createEffect(() => { - const key = state.active - if (typeof window === "undefined") return - window.__OPENCODE__ ??= {} - window.__OPENCODE__.activeServer = key - }) + const isReady = createMemo(() => ready() && !!state.active) - const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => store.projects[origin()] ?? []) - const current: Accessor = createMemo(() => { - const list = allServers() - const active = list.find((s) => ServerConnection.key(s) === state.active) - if (active) return active - if (!serversReady()) return - return list[0] - }) - const healthTarget = createMemo(() => { - const conn = current() - if (!conn) return "" - return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n") - }) - const isReady = createMemo(() => ready() && !!current()) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) createEffect(() => { - healthTarget() - const current_ = untrack(current) + const current_ = current() if (!current_) return if (props.disableHealthCheck) { @@ -266,14 +220,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( onCleanup(startHealthPolling(current_)) }) - createEffect(() => { - if (!serversReady()) return - const list = allServers() - if (!list.length) return - if (list.some((conn) => ServerConnection.key(conn) === state.active)) return - setState("active", nextActiveKey(state.active)) - }) - + const origin = createMemo(() => projectsKey(state.active)) + const projectsList = createMemo(() => store.projects[origin()] ?? []) + const current: Accessor = createMemo( + () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], + ) const isLocal = createMemo(() => { const c = current() return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) @@ -295,13 +246,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( get current() { return current() }, - canDefault() { - return !!platform.getDefaultServer && !!platform.setDefaultServer - }, - defaultKey() { - return state.default - }, - setDefault, setActive, add, remove, diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 179af4f13cb3..6e07e0312412 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -22,7 +22,7 @@ beforeAll(async () => { }) describe("getWorkspaceTerminalCacheKey", () => { - test("uses the workspace cache key", () => { + test("uses workspace-only directory cache key", () => { expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 1bb79ee05406..31d2d6e04ca8 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -3,7 +3,6 @@ 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" @@ -22,11 +21,6 @@ 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) } @@ -117,11 +111,10 @@ const trimTerminal = (pty: LocalPTY) => { } export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { + const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { - for (const [key, entry] of cache.entries()) { - if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue - entry.value.clear() - } + const entry = cache.get(key) + entry?.value.clear() } void removePersisted(Persist.workspace(dir, "terminal"), platform) @@ -137,25 +130,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession( - sdk: ReturnType, - dir: string, - serverKey: string, - legacySessionID?: string, -) { +function createWorkspaceTerminalSession(sdk: ReturnType, dir: 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( - target, + { + ...Persist.workspace(dir, "terminal", legacy), + migrate: migrateTerminalState, + }, createStore<{ active?: string all: LocalPTY[] @@ -164,14 +146,6 @@ function createWorkspaceTerminalSession( }), ) - debugTerminal("session.create", { - dir, - serverKey, - storage: target.storage, - key: target.key, - legacySessionID: legacySessionID ?? null, - }) - const pickNextTerminalNumber = () => { const existingTitleNumbers = new Set( store.all.flatMap((pty) => { @@ -212,16 +186,6 @@ function createWorkspaceTerminalSession( 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) { @@ -238,18 +202,11 @@ function createWorkspaceTerminalSession( const currentIndex = store.all.findIndex((item) => item.id === pty.id) if (currentIndex >= 0) setStore("all", currentIndex, previous) } - 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, - })}`, - ) + console.error("Failed to update terminal", 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 @@ -263,14 +220,6 @@ function createWorkspaceTerminalSession( }) 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(() => { @@ -303,19 +252,11 @@ function createWorkspaceTerminalSession( 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), @@ -348,12 +289,6 @@ function createWorkspaceTerminalSession( }, 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) @@ -422,7 +357,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() - const server = useServer() const params = useParams() const cache = new Map() @@ -430,9 +364,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(() => caches.delete(cache)) const disposeAll = () => { - const pending = Array.from(cache.values(), (entry) => entry.dispose) + for (const entry of cache.values()) { + entry.dispose() + } cache.clear() - for (const dispose of pending) dispose() } onCleanup(disposeAll) @@ -447,30 +382,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => { + 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 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, serverKey, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), dispose, })) @@ -479,21 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => { - const key = server.key - if (!key) return loadWorkspace(params.dir!, "", params.id) - return loadWorkspace(params.dir!, key, params.id) - }) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) createEffect( on( () => ({ dir: params.dir, id: params.id }), (next, prev) => { - const prevKey = server.key - if (!prev?.dir || !prevKey) return + if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prevKey, prev.id).trimAll() + loadWorkspace(prev.dir, prev.id).trimAll() }, { defer: true }, ), @@ -508,7 +426,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(), + bind: () => workspace(), 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/index.ts b/packages/app/src/index.ts index 2b5feecd74be..e85ea84a5ba4 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,5 +1,4 @@ export { AppBaseProviders, AppInterface } from "./app" -export { DialogWslServer } from "./components/dialog-wsl-server" 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" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3d3ec9bf051c..b71be13dabb1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -83,16 +83,9 @@ import { LocalWorkspace, SortableWorkspace, WorkspaceDragOverlay, - workspaceSortableDirectory, - workspaceSortableId, type WorkspaceSidebarContext, } from "./layout/sidebar-workspace" -import { - ProjectDragOverlay, - SortableProject, - projectSortableWorktree, - type ProjectSidebarContext, -} from "./layout/sidebar-project" +import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" export default function Layout(props: ParentProps) { @@ -1844,7 +1837,7 @@ export default function Layout(props: ParentProps) { ) function handleDragStart(event: unknown) { - const id = projectSortableWorktree(getDraggableId(event)) + const id = getDraggableId(event) if (!id) return setHoverProject(undefined) setStore("activeProject", id) @@ -1853,14 +1846,11 @@ export default function Layout(props: ParentProps) { function handleDragOver(event: DragEvent) { const { draggable, droppable } = event if (draggable && droppable) { - const from = projectSortableWorktree(draggable.id?.toString()) - const to = projectSortableWorktree(droppable.id?.toString()) - if (!from || !to) return const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === from) - const toIndex = projects.findIndex((p) => p.worktree === to) + const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) + const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== -1) { - layout.projects.move(from, toIndex) + layout.projects.move(draggable.id.toString(), toIndex) } } } @@ -1896,7 +1886,7 @@ export default function Layout(props: ParentProps) { }) function handleWorkspaceDragStart(event: unknown) { - const id = workspaceSortableDirectory(getDraggableId(event)) + const id = getDraggableId(event) if (!id) return setStore("activeWorkspace", id) } @@ -1904,16 +1894,13 @@ export default function Layout(props: ParentProps) { function handleWorkspaceDragOver(event: DragEvent) { const { draggable, droppable } = event if (!draggable || !droppable) return - const from = workspaceSortableDirectory(draggable.id?.toString()) - const to = workspaceSortableDirectory(droppable.id?.toString()) - if (!from || !to) return const project = sidebarProject() if (!project) return const ids = workspaceIds(project) - const fromIndex = ids.findIndex((dir) => dir === from) - const toIndex = ids.findIndex((dir) => dir === to) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return @@ -2274,13 +2261,13 @@ export default function Layout(props: ParentProps) { }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" > - + {(directory) => ( @@ -2337,7 +2324,6 @@ export default function Layout(props: ParentProps) { } const projects = () => layout.projects.list() - const projectIds = createMemo(() => projects().map((project) => project.worktree)) const projectOverlay = () => store.activeProject} /> const sidebarContent = (mobile?: boolean) => ( layout.sidebar.opened()} aimMove={aim.move} projects={projects} - projectIds={projectIds} - renderProject={(worktree) => { - const project = createMemo(() => projects().find((item) => item.worktree === worktree)) - return ( - - {(project) => ( - - )} - - ) - }} + renderProject={(project) => ( + + )} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragOver={handleDragOver} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index a4836b9efd3c..2ba20092c585 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -34,17 +34,6 @@ export type ProjectSidebarContext = { sessionProps: Omit } -const PROJECT_SORTABLE_PREFIX = "project:" - -export function projectSortableId(worktree: string) { - return `${PROJECT_SORTABLE_PREFIX}${worktree}` -} - -export function projectSortableWorktree(id: string | undefined) { - if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return - return id.slice(PROJECT_SORTABLE_PREFIX.length) -} - export const ProjectDragOverlay = (props: { projects: Accessor activeProject: Accessor @@ -286,7 +275,7 @@ export const SortableProject = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(projectSortableId(props.project.worktree)) + const sortable = createSortable(props.project.worktree) const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d9cd4d5a205b..ca36af2a421c 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -11,15 +11,13 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { type LocalProject } from "@/context/layout" -import { projectSortableId } from "./sidebar-project" export const SidebarContent = (props: { mobile?: boolean opened: Accessor aimMove: (event: MouseEvent) => void projects: Accessor - projectIds: Accessor - renderProject: (worktree: string) => JSX.Element + renderProject: (project: LocalProject) => JSX.Element handleDragStart: (event: unknown) => void handleDragEnd: () => void handleDragOver: (event: DragEvent) => void @@ -65,8 +63,8 @@ export const SidebarContent = (props: {
- - {(worktree) => props.renderProject(worktree)} + p.worktree)}> + {(project) => props.renderProject(project)} void } -const WORKSPACE_SORTABLE_PREFIX = "workspace:" - -export function workspaceSortableId(directory: string) { - return `${WORKSPACE_SORTABLE_PREFIX}${directory}` -} - -export function workspaceSortableDirectory(id: string | undefined) { - if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return - return id.slice(WORKSPACE_SORTABLE_PREFIX.length) -} - export const WorkspaceDragOverlay = (props: { sidebarProject: Accessor activeWorkspace: Accessor @@ -312,7 +301,7 @@ export const SortableWorkspace = (props: { const params = useParams() const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(workspaceSortableId(props.directory)) + const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) const [menu, setMenu] = createStore({ open: false, @@ -325,9 +314,7 @@ export const SortableWorkspace = (props: { const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) - const projectId = props.project.id - if (!projectId) return name - return props.ctx.workspaceName(props.directory, projectId, branch) ?? name + return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name }) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) @@ -357,7 +344,7 @@ export const SortableWorkspace = (props: { InlineEditor={props.ctx.InlineEditor} renameWorkspace={props.ctx.renameWorkspace} setEditor={props.ctx.setEditor} - projectId={props.project.id ?? ""} + projectId={props.project.id} /> ) @@ -460,21 +447,19 @@ export const LocalWorkspace = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() - const worktree = createMemo(() => props.project.worktree) const workspace = createMemo(() => { - const [store, setStore] = globalSync.child(worktree()) + const [store, setStore] = globalSync.child(props.project.worktree) return { store, setStore } }) - const slug = createMemo(() => base64Encode(worktree())) + const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const count = createMemo(() => sessions()?.length ?? 0) - const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) })) - const loading = createMemo(() => query.isPending && count() === 0) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) + const loading = () => query.isLoading && count() === 0 const loadMore = async () => { - const dir = worktree() workspace().setStore("limit", (limit) => (limit ?? 0) + 5) - await globalSync.project.loadSessions(dir) + await globalSync.project.loadSessions(props.project.worktree) } return ( diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts index 26821134c8a7..0c6189dafe56 100644 --- a/packages/app/src/utils/scoped-cache.test.ts +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -24,7 +24,7 @@ describe("createScopedCache", () => { expect(disposed).toEqual(["b"]) }) - test("disposes entries on delete and clear", async () => { + test("disposes entries on delete and clear", () => { const disposed: string[] = [] const cache = createScopedCache((key) => ({ key }), { dispose: (value) => disposed.push(value.key), @@ -39,9 +39,6 @@ describe("createScopedCache", () => { cache.clear() expect(cache.peek("b")).toBeUndefined() - // clear() defers dispose to a microtask to avoid nested cleanNode cascades - // when called from inside an onCleanup; flush the queue before asserting. - await Promise.resolve() expect(disposed).toEqual(["a", "b"]) }) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts index 7044cdf03c63..224c363c1ebc 100644 --- a/packages/app/src/utils/scoped-cache.ts +++ b/packages/app/src/utils/scoped-cache.ts @@ -89,21 +89,10 @@ export function createScopedCache(createValue: (key: string) => T, options: S } const clear = () => { - // Defer dispose() calls to a microtask. When clear() runs inside an - // onCleanup during a parent remount (e.g. context/file.tsx and - // context/comments.tsx both do this), synchronous dispose on cached - // createRoot entries starts a nested cleanNode cascade while the outer - // cascade is mid-traversal, corrupting solid-js's graph walk state and - // throwing `Cannot read properties of null (reading '1')` at - // chunk-*.js:992. Deferring lets the outer cleanup finish first. - const pending: Array<[string, Entry]> = [] - for (const entry of store) pending.push(entry) - store.clear() - if (pending.length && options.dispose) { - queueMicrotask(() => { - for (const [key, entry] of pending) dispose(key, entry) - }) + for (const [key, entry] of store) { + dispose(key, entry) } + store.clear() } return { diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 52c82dbebf28..a13fd34ef728 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -87,10 +87,7 @@ export async function checkServerHealth( signal, }) .global.health() - .then((x) => { - if (x.error) return next(count, x.error) - return { healthy: x.data?.healthy === true, version: x.data?.version } - }) + .then((x) => (x.error ? next(count, x.error) : { 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/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 760662732e8a..a352e03fdd4a 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -82,13 +82,6 @@ export default defineConfig({ plugins: [appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", - server: { - host: "127.0.0.1", - strictPort: true, - hmr: { - host: "127.0.0.1", - }, - }, define: { "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 9a6bb53c64fd..bf8f692e7f13 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -7,5 +7,4 @@ 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_SERVERS_KEY = "wslServers" -export const LEGACY_LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index ad1335989287..b12dcb3b1749 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -37,13 +37,12 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" +import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" -import { getStore } from "./store" import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, @@ -63,7 +62,6 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() -void serverReady.promise.catch(() => undefined) const logger = initLogging() const wslServers = createWslServersController( app.getVersion(), @@ -83,16 +81,6 @@ 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: getStore().path, - wslServersKey: WSL_SERVERS_KEY, - wslServers: getStore().get(WSL_SERVERS_KEY) ?? null, -}) setupApp() @@ -100,14 +88,6 @@ function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - process.on("uncaughtException", (error) => { - logger.error("main process uncaught exception", error) - }) - - process.on("unhandledRejection", (reason) => { - logger.error("main process unhandled rejection", reason) - }) - if (!app.requestSingleInstanceLock()) { app.quit() return @@ -182,11 +162,6 @@ async function initialize() { const url = `http://${hostname}:${port}` const password = randomUUID() - const startupData: ServerReadyData = { - url, - username: "opencode", - password, - } const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -207,40 +182,26 @@ async function initialize() { initEmitter.emit("sqlite", { type: "Done" }) } - logger.log("spawning windows sidecar", { url }) - let startupError: Error | null = null - const startup = await (async () => { - try { - return await spawnLocalServer(hostname, port, password) - } catch (error) { - startupError = asError(error) - logger.error("windows sidecar startup failed", startupError) - return undefined - } - })() - server = startup?.listener ?? null + logger.log("spawning sidecar", { url }) + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener + serverReady.resolve({ + url, + username: "opencode", + password, + }) // Initialize WSL sidecars in parallel; failures do not block app startup. - void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) - - if (startup) { - await Promise.race([ - startup.health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]) - .then(() => { - serverReady.resolve(startupData) - }) - .catch((error) => { - startupError = asError(error) - logger.error("sidecar health check failed", startupError) - serverReady.reject(startupError) - }) - } else { - serverReady.reject(startupError ?? new Error("Local server startup failed")) - } + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + + await Promise.race([ + health.wait, + delay(30_000).then(() => { + throw new Error("Sidecar health check timed out") + }), + ]).catch((error) => { + logger.error("sidecar health check failed", error) + }) logger.log("loading task finished") })() @@ -249,7 +210,6 @@ async function initialize() { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { overlay = createLoadingWindow() - wireWindowDiagnostics(overlay, "loading") await delay(1_000) } } @@ -262,67 +222,11 @@ async function initialize() { } mainWindow = createMainWindow() - wireWindowDiagnostics(mainWindow, "main") wireMenu() overlay?.close() } -function wireWindowDiagnostics(win: BrowserWindow, label: string) { - win.webContents.on("console-message", (_event, level, message, line, sourceId) => { - // Render `message` as a block so multi-line stack traces survive; the - // previous shape stuffed the message into a JSON object which escaped - // `\n` and made stacks unreadable. - const location = sourceId ? ` [${sourceId}:${line}]` : "" - const text = `${label} renderer${location}\n${message}` - if (level >= 3) { - logger.error(text) - return - } - if (level >= 2) { - logger.warn(text) - return - } - logger.log(text) - }) - - win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - logger.error(`${label} renderer failed load`, { - errorCode, - errorDescription, - validatedURL, - isMainFrame, - }) - }) - - win.webContents.on("render-process-gone", (_event, details) => { - logger.error(`${label} renderer process gone`, details) - }) - - win.webContents.on("preload-error", (_event, path, error) => { - logger.error(`${label} preload error`, { - path, - error: error instanceof Error ? (error.stack ?? error.message) : String(error), - }) - }) - - // DevTools accelerators on Windows/Linux where the menu isn't created. - win.webContents.on("before-input-event", (_event, input) => { - if (input.type !== "keyDown") return - const key = input.key - const toggle = - key === "F12" || - (input.control && input.shift && (key === "I" || key === "i")) || - (input.meta && input.alt && (key === "I" || key === "i")) - if (!toggle) return - win.webContents.toggleDevTools() - }) - - win.on("unresponsive", () => { - logger.error(`${label} window became unresponsive`) - }) -} - function wireMenu() { if (!mainWindow) return createMenu({ @@ -543,10 +447,6 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function asError(error: unknown) { - return error instanceof Error ? error : new Error(String(error)) -} - function defer() { let resolve!: (value: T) => void let reject!: (error: Error) => void diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index 2c0b25b6adfd..70e3dc9c7503 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -67,8 +67,7 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { - const store = getStore() - if (store.get(TAURI_MIGRATED_KEY)) { + if (getStore().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 fa476ea0ec58..394fc80665d1 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -10,18 +10,11 @@ import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from ". export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | 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 + const value = getStore().get(DEFAULT_SERVER_URL_KEY) + return typeof value === "string" ? value : null } export function setDefaultServerUrl(url: string | null) { - const store = getStore() if (url) { getStore().set(DEFAULT_SERVER_URL_KEY, url) return diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index b65f20a8555f..61f0c0a4938c 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,12 +4,10 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() -// 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. +// 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). export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 1e23c3940dee..337e1ca0bcc4 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -177,9 +177,7 @@ export function registerRendererProtocol() { function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { - const base = new URL(devUrl) - if (base.hostname === "localhost") base.hostname = "127.0.0.1" - const url = new URL(html, base) + const url = new URL(html, devUrl) void win.loadURL(url.toString()) return } diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 8614c6647b31..34cf62f9d779 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -11,7 +11,7 @@ import type { WslServersEvent, WslServersState, } from "../preload/types" -import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" +import { WSL_SERVERS_KEY } from "./constants" import { getStore } from "./store" import { installWslDistro, @@ -375,24 +375,7 @@ function readPersistedServers(): WslServerConfig[] { const list = Array.isArray(record.servers) ? record.servers : [] return list.flatMap(normalizePersistedServer) } - const migrated = migrateLegacyLocalServer() - if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) - return migrated -} - -function migrateLegacyLocalServer(): WslServerConfig[] { - const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY) - if (!legacy || typeof legacy !== "object") return [] - const record = legacy as Record - if (record.mode !== "wsl") return [] - const distro = typeof record.distro === "string" ? record.distro : null - if (!distro) return [] - return [ - { - id: wslServerIdForDistro(distro), - distro, - }, - ] + return [] } function normalizePersistedServer(value: unknown): WslServerConfig[] { diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index f631a722be02..d1905970041a 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,57 +1,5 @@ // @refresh reload -// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so -// reported errors come with a useful frame budget. -Error.stackTraceLimit = 200 - -// Install global error listeners before any other module runs so that -// uncaught errors and rejected promises reach the main process with their -// full stacks intact. Electron's `console-message` event only forwards the -// rethrow site, so without these we lose the originating frame. -window.addEventListener("error", (event) => { - const err = event.error - const stack = err instanceof Error ? err.stack : null - console.error( - "[renderer uncaught]", - stack ?? event.message, - stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`, - ) -}) - -window.addEventListener("unhandledrejection", (event) => { - const reason = event.reason - // Log as much as possible: stack for Errors, JSON for plain objects with - // a fallback to a tagged shape so we never end up with just - // "[object Object]" in main.log. - if (reason instanceof Error) { - console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason)) - return - } - let serialized: string - try { - serialized = JSON.stringify( - reason, - (_key, value) => { - if (value instanceof Error) { - return { __error: true, name: value.name, message: value.message, stack: value.stack } - } - return value - }, - 2, - ) - } catch { - serialized = String(reason) - } - console.error( - "[renderer unhandled rejection]", - `type=${typeof reason}`, - `ctor=${reason?.constructor?.name ?? "null"}`, - `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`, - "value:", - serialized, - ) -}) - import { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, @@ -76,7 +24,6 @@ import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { webviewZoom } from "./webview-zoom" import "./styles.css" -import { Button } from "@opencode-ai/ui/button" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" @@ -121,21 +68,6 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } -function LocalServerStartupError(props: { message: string }) { - return ( -
-
- -

Local Server failed to start

-

{props.message}

- -
-
- ) -} - const createPlatform = (): Platform => { const os = (() => { const ua = navigator.userAgent @@ -351,19 +283,7 @@ render(() => { const [windowCount] = createResource(() => window.api.getWindowCount()) - const [startup] = createResource(async () => { - try { - return { - error: null, - sidecar: await window.api.awaitInitialization(() => undefined), - } - } catch (error) { - return { - error: error instanceof Error ? error.message : String(error), - sidecar: null, - } - } - }) + const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(loadLocale) @@ -405,12 +325,12 @@ render(() => { const ready = createMemo( () => !defaultServer.loading && - !startup.loading && + !sidecar.loading && !windowCount.loading && !locale.loading, ) const servers = createMemo(() => { - const data = startup.latest?.sidecar + const data = sidecar() const list: ServerConnection.Any[] = [] if (data) { list.push({ @@ -442,14 +362,10 @@ render(() => { return list }) if (!ready()) return splash - if (startup.latest?.error) { - return - } return ( diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 523b12e72f57..070d0c71f4f2 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -24,20 +24,7 @@ pub fn get_default_server_url(app: AppHandle) -> Result, String> let value = store.get(DEFAULT_SERVER_URL_KEY); match value { - 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), - }, + Some(v) => Ok(v.as_str().map(String::from)), None => Ok(None), } } @@ -67,18 +54,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu #[tauri::command] #[specta::specta] -pub fn get_wsl_config(app: AppHandle) -> Result { - let store = app - .store(SETTINGS_STORE) - .map_err(|e| format!("Failed to open settings store: {}", e))?; - - let enabled = store - .get(WSL_ENABLED_KEY) - .as_ref() - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - Ok(WslConfig { enabled }) +pub fn get_wsl_config(_app: AppHandle) -> Result { + // let store = app + // .store(SETTINGS_STORE) + // .map_err(|e| format!("Failed to open settings store: {}", e))?; + + // let enabled = store + // .get(WSL_ENABLED_KEY) + // .as_ref() + // .and_then(|v| v.as_bool()) + // .unwrap_or(false); + + Ok(WslConfig { enabled: false }) } #[tauri::command] diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index c90a890e2b14..1a0da014dd43 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -71,21 +71,16 @@ const createPlatform = (): Platform => { })() const wslHome = async () => { - if (os !== "windows") return undefined - const wsl = await commands.getWslConfig().catch(() => null) - if (!wsl?.enabled) return undefined + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined return commands.wslPath("~", "windows").catch(() => undefined) } - const handleWslPicker = async (result: T): Promise => { - if (!result) return result - const wsl = await commands.getWslConfig().catch(() => null) - if (!wsl?.enabled) return result - const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path) + const handleWslPicker = async (result: T | null): Promise => { + if (!result || !window.__OPENCODE__?.wsl) return result if (Array.isArray(result)) { - return (await Promise.all(result.map(convert))) as T + return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any } - return (await convert(result)) as T + return commands.wslPath(result, "linux").catch(() => result) as any } return { @@ -354,6 +349,16 @@ const createPlatform = (): Platform => { } }, + getWslEnabled: async () => { + const next = await commands.getWslConfig().catch(() => null) + if (next) return next.enabled + return window.__OPENCODE__!.wsl ?? false + }, + + setWslEnabled: async (enabled) => { + await commands.setWslConfig({ enabled }) + }, + getDefaultServer: async () => { const url = await commands.getDefaultServerUrl().catch(() => null) if (!url) return null @@ -433,7 +438,11 @@ render(() => { // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) - const [defaultServer] = createResource(() => platform.getDefaultServer?.()) + const [defaultServer] = createResource(() => + platform.getDefaultServer?.().then((url) => { + if (url) return ServerConnection.key({ type: "http", http: { url } }) + }), + ) const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive @@ -482,7 +491,7 @@ render(() => { {(_) => { return ( diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 35812f953ddf..2f2edb4ff5ac 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 401126949569..2026d8232487 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -9,8 +8,7 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" import { Effect } from "effect" @@ -35,7 +33,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -63,176 +61,172 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } - - const project = Instance.project - - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: Instance.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } + const project = ctx.project - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", - }, - { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } - await fs.mkdir(targetPath, { recursive: true }) + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - await Filesystem.write(filePath, content) + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } + + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) + await fs.mkdir(targetPath, { recursive: true }) + + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } - }, + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) - }, + }), }) const AgentListCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e707526dfee9..a4a209ea39a4 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,10 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { WithInstance } from "@/project/with-instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -200,191 +199,192 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() - - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p - }) - - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() + + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) - await addWorkflowFiles() - printNextSteps() + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") - } + await addWorkflowFiles() + printNextSteps() - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") } - async function getAppInfo() { - const project = Instance.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() } - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider + // Get repo info + const info = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), + ).then((x) => x.text().trim()) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - }) + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), + }) - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } + if (prompts.isCancel(provider)) throw new UI.CancelledError() - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") + return provider + } - // Get installation - const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") - - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) + } + }) - s.stop("Installed GitHub app") + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { + const installation = await getInstallation() + if (installation) break - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -415,17 +415,16 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } - }, + } }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -438,8 +437,10 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -502,21 +503,21 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -1646,5 +1647,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e4d7bd9224e6..2ae7cece6a27 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -9,8 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -18,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -65,35 +65,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -110,73 +106,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") - - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) - - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return - } + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] - - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } + + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -186,98 +177,98 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") - - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) - - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") + + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) + + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return - } - - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + prompts.outro("Done") + return + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { if (status.status === "connected") { spinner.stop("Authentication successful!") } else if (status.status === "needs_client_registration") { @@ -301,55 +292,53 @@ export const McpAuthCommand = cmd({ } else { spinner.stop("Unexpected status: " + status.status, 1) } - } catch (error) { + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") - - const { config, auth } = await authState() - const servers = oauthServers(config) - - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + const { config, auth } = yield* authState() + const servers = oauthServers(config) - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url + + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } + + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -357,57 +346,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") - - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) - - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } + + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } + + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { @@ -445,171 +431,171 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add MCP server") - - const project = Instance.project - - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), - resolveConfigPath(Global.Path.config, true), - ]) - - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add MCP server") + + const project = ctx.project + + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) + + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() + + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } + + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") - }, + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -618,182 +604,172 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Debug") - - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name - - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("MCP OAuth Debug") + + const mcpServers = config.mcp ?? {} + const serverName = args.name + + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return - } + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) + + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) } + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) + } + } - const spinner = prompts.spinner() - spinner.start("Testing connection...") - - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + const spinner = prompts.spinner() + spinner.start("Testing connection...") + + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), - }) + id: 1, + }), + }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") - prompts.log.info("Testing OAuth flow (without completing authorization)...") + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, - }) + prompts.log.info("Testing OAuth flow (without completing authorization)...") - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, + }) + + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, + }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) + + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") - }, + prompts.outro("Debug complete") }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index ca6452618231..081bcece000b 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,6 +1,7 @@ import { Auth } from "../../auth" import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" @@ -13,7 +14,6 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -232,58 +232,59 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { - UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() - - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) - } + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.promise(async () => { + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(await Effect.runPromise(authSvc.all())) + const database = await Effect.runPromise(modelsDev.get()) + + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${results.length} credentials`) - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } } } - } - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) - } + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) - } - }, + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + } + }) + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -302,228 +303,219 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + handler: Effect.fn("Cli.providers.login")(function* (args) { + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { + auth: { command: string[]; env: string } + } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { + stdout: "pipe", + stderr: "inherit", + }) + if (!proc.stdout) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) + await put(url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), + }) + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + await refreshModels().catch(() => {}) - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const config = await Effect.runPromise(cfgSvc.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } + const providers = await getModels().then((x) => { + const filtered: Record = {} + for (const [key, value] of Object.entries(x)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + return filtered + }) + const hooks = await Effect.runPromise(pluginSvc.list()) + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] - - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } - - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) - if (handled) return + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] + + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) } + provider = match.value + } else { + const selected = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...options, + { + value: "other", + label: "Other", + }, + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + provider = selected as string + } - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + if (handled) return + } - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + if (provider === "other") { + const custom = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(custom)) throw new UI.CancelledError() + provider = custom.replace(/^@ai-sdk\//, "") - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) + if (handled) return } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } - prompts.outro("Done") - }, + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await put(provider, { + type: "api", + key, + }) + + prompts.outro("Done") }) - }, + }), }) -export const ProvidersLogoutCommand = cmd({ +export const ProvidersLogoutCommand = effectCmd({ command: "logout", describe: "log out from a configured provider", - async handler(_args) { - UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await getModels() - const selected = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.promise(async () => { + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = await Effect.runPromise(modelsDev.get()) + const selected = await prompts.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await Effect.runPromise(authSvc.remove(providerID)) + prompts.outro("Logout successful") }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8bf7b2345c90..0124a26932d6 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { totalSessions: number @@ -69,38 +68,28 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - return yield* run(args, ctx.project) - }), -}) - -const run = ( - args: { days?: number; tools?: number; models?: unknown; project?: string }, - currentProject: Project.Info, -) => - Effect.promise(async () => { - const stats = await aggregateSessionStats(args.days, args.project, currentProject) - + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) let modelLimit: number | undefined if (args.models === true) { modelLimit = Infinity } else if (typeof args.models === "number") { modelLimit = args.models } - displayStats(stats, args.tools, modelLimit) - }) + }), +}) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -export async function aggregateSessionStats( +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, projectFilter?: string, currentProject?: Project.Info, -): Promise { - const sessions = await getAllSessions() +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -169,122 +158,111 @@ export async function aggregateSessionStats( const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) - - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) - - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number - } + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) + + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number } - cost: number - } - > = {} - - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + > = {} + + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } - } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 + } } } - } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -313,7 +291,7 @@ export async function aggregateSessionStats( : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index b0f6de16b711..ada5f8677d77 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -82,17 +83,21 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((store) => - store.provide( - { directory }, - Effect.gen(function* () { - const ctx = yield* InstanceRef - const body = opts.handler(args) - return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body - }), - ), - ), + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } }, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f483f5..4a67ba036e06 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1777b875aa8c..1f4cbcf1e0c3 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema" import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ identifier: "Session.Delivery", @@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ id: SessionID, - parentID: SessionID.pipe(Schema.optional), + parentID: optionalOmitUndefined(SessionID), projectID: ProjectID, - workspaceID: WorkspaceID.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), model: Schema.Struct({ id: ModelID, providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: optionalOmitUndefined(Schema.String), + }).pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), }), title: Schema.String, /* @@ -109,7 +110,7 @@ export const layer = Layer.effect( decodeMessage({ ...row.data, id: row.id, type: row.type }) function fromRow(row: typeof SessionTable.$inferSelect): Info { - return { + return new Info({ id: SessionID.make(row.id), projectID: ProjectID.make(row.project_id), workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, @@ -129,7 +130,7 @@ export const layer = Layer.effect( updated: DateTime.makeUnsafe(row.time_updated), archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, }, - } + }) } const result: Interface = { diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 000000000000..de6fed8daa68 --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 000000000000..6922d8c43f78 --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index db6c750f9b0d..1e74763ae2d8 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -35,7 +35,7 @@ width: 100%; max-height: 100%; min-height: 280px; - overflow: hidden; + overflow: auto; pointer-events: auto; /* Hide scrollbar */ @@ -102,8 +102,7 @@ display: flex; flex-direction: column; flex: 1; - min-height: 0; - overflow-y: auto; + overflow: hidden; &:focus-visible { outline: none; diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index a377a159b023..c1c56212b5f6 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -29,33 +29,33 @@ const Context = createContext>() function init() { const [active, setActive] = createSignal() - let timer: ReturnType | undefined - let locked = false + const timer = { current: undefined as ReturnType | undefined } + const lock = { value: false } onCleanup(() => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined }) const close = () => { const current = active() - if (!current || locked) return - locked = true + if (!current || lock.value) return + lock.value = true current.onClose?.() current.setClosing(true) const id = current.id - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined } - timer = setTimeout(() => { - timer = undefined + timer.current = setTimeout(() => { + timer.current = undefined current.dispose() if (active()?.id === id) setActive(undefined) - locked = false + lock.value = false }, 100) } @@ -80,11 +80,11 @@ function init() { setActive(undefined) } - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined } - locked = false + lock.value = false const id = Math.random().toString(36).slice(2) let dispose: (() => void) | undefined @@ -105,7 +105,7 @@ function init() { }} > - + {element()} @@ -115,14 +115,7 @@ function init() { if (!dispose || !setClosing) return - setActive({ - id, - node, - dispose, - owner, - onClose, - setClosing, - }) + setActive({ id, node, dispose, owner, onClose, setClosing }) } return { From 7dd8b2b7f9e568b881dded27f5ce1625908d9c78 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 11:24:14 +1000 Subject: [PATCH 81/88] deslop --- packages/app/src/app.tsx | 1 - .../src/components/dialog-select-server.tsx | 29 +------------------ packages/app/src/context/platform.tsx | 6 ---- 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2649260cf3e3..a15b65cd84ce 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -75,7 +75,6 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] - wsl?: boolean activeServer?: string } api?: { diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 909d45ae710d..7504eb86fa4a 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -20,29 +20,6 @@ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" -function versionOlderThan(current: string | null | undefined, expected: string | null | undefined) { - if (!current || !expected) return false - - const parse = (value: string) => { - const match = value.match(/v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/) - if (!match) return - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: match[4] ?? null, - } - } - - const left = parse(current) - const right = parse(expected) - if (!left || !right) return false - if (left.major !== right.major) return left.major < right.major - if (left.minor !== right.minor) return left.minor < right.minor - if (left.patch !== right.patch) return left.patch < right.patch - return !!left.prerelease && !right.prerelease -} - interface DialogSelectServerProps { onNavigateHome?: () => void } @@ -632,15 +609,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const blocked = () => health(key)?.healthy === false const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh" const canRemove = () => i.type === "http" || wsl - const outdated = () => { - const check = wslCheck(i) - return versionOlderThan(check?.version, check?.expectedVersion) - } const opencodeAction = () => { const check = wslCheck(i) if (!check) return null if (!check.resolvedPath) return "Install OpenCode" - if (outdated()) return "Update OpenCode" + if (check.matchesDesktop === false) return "Update OpenCode" return null } const updating = () => { diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 757c8e49a52e..928ed5ee2e7f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -147,12 +147,6 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void - /** Get the configured WSL integration (desktop only) */ - getWslEnabled?(): Promise - - /** Set the configured WSL integration (desktop only) */ - setWslEnabled?(config: boolean): Promise | void - /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform From 58f569fbf72c677380eddc696dd54f4efb283dca Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 11:26:25 +1000 Subject: [PATCH 82/88] deslop --- packages/app/src/components/dialog-select-server.tsx | 3 +-- packages/app/src/components/dialog-wsl-server.tsx | 10 ++-------- packages/app/src/context/platform.tsx | 1 - packages/desktop-electron/src/main/wsl.ts | 6 +----- packages/desktop-electron/src/preload/types.ts | 1 - 5 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 7504eb86fa4a..b51431c93f70 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -275,7 +275,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, onSuccess: async (key) => { server.remove(key) - if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) }, onError: (err) => showRequestError(language, err), })) @@ -607,7 +606,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const wsl = isWslSidecar(i) const wslDistro = wsl ? i.distro : undefined const blocked = () => health(key)?.healthy === false - const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh" + const canChangeDefault = () => defaultServer.canDefault() && i.type === "http" const canRemove = () => i.type === "http" || wsl const opencodeAction = () => { const check = wslCheck(i) diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index 9fdd85b663fa..99ff342beb2c 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -61,7 +61,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const distroWarningProbe = createMemo(() => { const probe = selectedProbe() if (!probe) return null - if (distroReady() && !probe.isRoot) return null + if (distroReady()) return null return probe }) const distroUnavailableMessage = createMemo(() => { @@ -453,8 +453,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { when={ selectedInstalled()?.version === 1 || distroUnavailableMessage() || - distroMissingTools() || - distroWarningProbe()?.isRoot + distroMissingTools() } >
@@ -467,11 +466,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
This distro needs bash and curl.
- -
- This distro is using the root user right now. -
-
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 928ed5ee2e7f..64b664393b66 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -28,7 +28,6 @@ export type WslDistroProbe = { canExecute: boolean hasBash: boolean hasCurl: boolean - isRoot: boolean | null error: string | null } export type WslOpencodeCheck = { diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index b8267bc77d25..7fb3a23cbdc3 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -348,24 +348,20 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis canExecute: false, hasBash: false, hasCurl: false, - isRoot: null, error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", } } - const [bash, curl, user] = await Promise.all([ + 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), - runWslSh("id -un 2>/dev/null || true", name, opts), ]) - const username = summarize(user.stdout) return { name, canExecute: true, hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", - isRoot: username ? username === "root" : null, error: null, } } diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index b8bb7790b13a..34fd5be6aa49 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -27,7 +27,6 @@ export type WslDistroProbe = { canExecute: boolean hasBash: boolean hasCurl: boolean - isRoot: boolean | null error: string | null } export type WslOpencodeCheck = { From 777169ac179e1c38ef93874b721fbae51d6141ee Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 11:28:57 +1000 Subject: [PATCH 83/88] deslop --- packages/desktop-electron/src/main/ipc.ts | 138 ++++++++-------------- 1 file changed, 52 insertions(+), 86 deletions(-) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 82563e6aa662..27c8506aa6b8 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -60,40 +60,6 @@ export function registerIpcHandlers(deps: Deps) { throw new Error(`Invalid ${name}`) } - const trustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { - const raw = event.senderFrame?.url ?? event.sender.getURL() - try { - const url = new URL(raw) - if (url.protocol === "oc:" && url.hostname === "renderer") return true - if (!app.isPackaged && (url.hostname === "127.0.0.1" || url.hostname === "localhost")) return true - } catch { - return false - } - return false - } - - const requireTrustedSender = (event: IpcMainEvent | IpcMainInvokeEvent) => { - if (trustedSender(event)) return - throw new Error("Untrusted IPC sender") - } - - const handle = ( - channel: string, - listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown, - ) => { - ipcMain.handle(channel, (event, ...args) => { - requireTrustedSender(event) - return listener(event, ...(args as Args)) - }) - } - - const on = (channel: string, listener: (event: IpcMainEvent, ...args: Args) => void) => { - ipcMain.on(channel, (event, ...args) => { - if (!trustedSender(event)) return - listener(event, ...(args as Args)) - }) - } - const wslSubscriptions = new Map void>() const unsubscribeWsl = (id: number) => { const off = wslSubscriptions.get(id) @@ -107,12 +73,12 @@ export function registerIpcHandlers(deps: Deps) { wslSubscriptions.clear() }) - handle("kill-sidecar", () => deps.killSidecar()) - handle("await-initialization", (event: IpcMainInvokeEvent) => { + ipcMain.handle("kill-sidecar", () => deps.killSidecar()) + ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) - handle("wsl-servers-subscribe", (event) => { + ipcMain.handle("wsl-servers-subscribe", (event) => { const id = event.sender.id if (wslSubscriptions.has(id)) return wslSubscriptions.set( @@ -127,83 +93,83 @@ export function registerIpcHandlers(deps: Deps) { ) event.sender.once("destroyed", () => unsubscribeWsl(id)) }) - handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id)) - handle("wsl-servers-get-state", () => deps.getWslServersState()) - handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) - handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) - handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) - handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id)) + ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState()) + ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime()) + ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros()) + ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl()) + ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => deps.wslServersInstallDistro(requireString("distro", name)), ) - handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => + ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) => deps.wslServersProbeDistro(requireString("distro", name)), ) - handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => + ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) => deps.wslServersProbeOpencode(requireString("distro", name)), ) - handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => deps.wslServersInstallOpencode(requireString("distro", name)), ) - handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => deps.wslServersOpenTerminal(requireString("distro", name)), ) - handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(requireString("distro", distro)), ) - handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(requireString("server id", id)), ) - handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(requireString("server id", id)), ) - handle("get-window-config", () => deps.getWindowConfig()) - handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) - handle("get-default-server-url", () => deps.getDefaultServerUrl()) - handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => + ipcMain.handle("get-window-config", () => deps.getWindowConfig()) + ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) + ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) + ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) - handle("get-display-backend", () => deps.getDisplayBackend()) - handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => + ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) + ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), ) - handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) - handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) - handle( + 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, distro?: string | null) => deps.wslPath(path, mode, distro), ) - handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - on("loading-window-complete", () => deps.loadingWindowComplete()) - handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) - handle("check-update", () => deps.checkUpdate()) - handle("install-update", () => deps.installUpdate()) - handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) - handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) + ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) + ipcMain.handle("check-update", () => deps.checkUpdate()) + ipcMain.handle("install-update", () => deps.installUpdate()) + ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) if (value === undefined || value === null) return null return typeof value === "string" ? value : JSON.stringify(value) }) - handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { getStore(name).set(key, value) }) - handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { getStore(name).delete(key) }) - handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { + ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() }) - handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store) }) - handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store).length }) - handle( + ipcMain.handle( "open-directory-picker", async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ @@ -216,7 +182,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - handle( + ipcMain.handle( "open-file-picker", async ( _event: IpcMainInvokeEvent, @@ -233,7 +199,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - handle( + ipcMain.handle( "save-file-picker", async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { const result = await dialog.showSaveDialog({ @@ -245,11 +211,11 @@ export function registerIpcHandlers(deps: Deps) { }, ) - on("open-link", (_event: IpcMainEvent, url: string) => { + ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { void shell.openExternal(url) }) - handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { if (!app) return shell.openPath(path) await new Promise((resolve, reject) => { const [cmd, args] = @@ -258,7 +224,7 @@ export function registerIpcHandlers(deps: Deps) { }) }) - handle("read-clipboard-image", () => { + ipcMain.handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null const buffer = image.toPNG().buffer @@ -266,34 +232,34 @@ export function registerIpcHandlers(deps: Deps) { return { buffer, width: size.width, height: size.height } }) - on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { + ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { new Notification({ title, body }).show() }) - handle("get-window-count", () => BrowserWindow.getAllWindows().length) + ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length) - handle("get-window-focused", (event: IpcMainInvokeEvent) => { + ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false }) - handle("set-window-focus", (event: IpcMainInvokeEvent) => { + ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() }) - handle("show-window", (event: IpcMainInvokeEvent) => { + ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.show() }) - on("relaunch", () => { + ipcMain.on("relaunch", () => { deps.relaunch() }) - handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) - handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) + ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return setTitlebar(win, theme) From cecaf82445926074954dff61a9fd00bf37d4fcca Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 13:20:21 +1000 Subject: [PATCH 84/88] deslop --- .../src/components/dialog-select-server.tsx | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index b51431c93f70..ef62315bb558 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, createResource, onCleanup, Show, startTransition, untrack } from "solid-js" +import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -324,10 +324,19 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { if (!isWslSidecar(conn)) return return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime } + const nonReadyWslServers = createMemo(() => + (wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"), + ) const canRetryWsl = (conn: ServerConnection.Any) => { const runtime = wslRuntime(conn) return runtime?.kind === "failed" || runtime?.kind === "stopped" } + const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped" + const wslRuntimeLabel = (kind: string) => { + if (kind === "starting") return "Starting" + if (kind === "failed") return "Failed" + return "Stopped" + } const sortedItems = createMemo(() => { const list = items() @@ -586,6 +595,68 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } > + 0}> +
+
+ + {(item) => { + const key = ServerConnection.Key.make(item.config.id) + const retryable = () => canRetryWslRuntime(item.runtime.kind) + return ( +
+
+
+ {item.config.distro} + + WSL + + + {wslRuntimeLabel(item.runtime.kind)} + +
+ + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + + retryWslMutation.mutate(key)}> + Retry start + + + + + + removeWslMutation.mutate(key)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + + {language.t("dialog.server.menu.delete")} + + + + + +
+ ) + }} + +
+
+ Date: Mon, 4 May 2026 20:02:58 +1000 Subject: [PATCH 85/88] fix(desktop): remove stale WSL toggle plumbing --- packages/desktop/src/index.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 1a0da014dd43..021b9b467118 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -71,16 +71,11 @@ const createPlatform = (): Platform => { })() const wslHome = async () => { - if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined - return commands.wslPath("~", "windows").catch(() => undefined) + return undefined } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__OPENCODE__?.wsl) return result - if (Array.isArray(result)) { - return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any - } - return commands.wslPath(result, "linux").catch(() => result) as any + return result } return { @@ -349,16 +344,6 @@ const createPlatform = (): Platform => { } }, - getWslEnabled: async () => { - const next = await commands.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__OPENCODE__!.wsl ?? false - }, - - setWslEnabled: async (enabled) => { - await commands.setWslConfig({ enabled }) - }, - getDefaultServer: async () => { const url = await commands.getDefaultServerUrl().catch(() => null) if (!url) return null From 5778008596fd1dbdaac63c0113deac443097bf09 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 20:18:28 +1000 Subject: [PATCH 86/88] deslop --- packages/desktop-electron/src/main/apps.ts | 2 +- packages/desktop-electron/src/main/server.ts | 13 +-- packages/desktop-electron/src/main/wsl.ts | 114 +------------------ 3 files changed, 11 insertions(+), 118 deletions(-) diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index 0091f35e7db9..8586c271c4ce 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -45,7 +45,7 @@ export async function wslPath(path: string, mode: "windows" | "linux" | null, di const flag = mode === "windows" ? "-w" : "-u" try { - const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path + const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved const output = await runWslInDistro(["wslpath", flag, input], distro) if (output.code !== 0) { diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 394fc80665d1..47d2daba3913 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -84,15 +84,10 @@ export async function spawnWslSidecar( distro: string, opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { - // Every wsl.exe invocation below goes through wslArgs which injects - // `--user root`. That matters even when a distro has DefaultUid=0 - // (i.e. the interactive first-run user account setup never ran): - // explicit --user root bypasses the OOBE hook that would otherwise - // prompt on stdin, so we can resolve opencode and spawn the sidecar - // without any machine-wide first-run handshake. The earlier Ubuntu - // hang was caused by invoking without --user (default uid 0 triggers - // OOBE), not by the registry state itself. We still have a 20s - // timeout in runCommand as a safety net for true wsl.exe wedges. + // Do not pass --user here: the sidecar should inherit the distro's + // default user so config, auth, git, ssh, and file ownership match the + // user's normal WSL environment. If that default user is root, WSL will + // choose root itself. const opencode = await resolveWslOpencode(distro) if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 7fb3a23cbdc3..582608d3c8a6 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -33,17 +33,9 @@ export 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 -// for a username/password on its first invocation; when spawned with -// piped stdio that prompt blocks forever or silently reads garbage, -// leaving the sidecar hanging and the server unhealthy. Running as root -// sidesteps the entire first-run setup flow — opencode only needs an -// HTTP listener in the distro, not a per-user environment, so root is -// a safe default for the sidecar process. export function wslArgs(args: string[], distro?: string | null) { - if (distro) return ["-d", distro, "--user", "root", "--", ...args] - return ["--user", "root", "--", ...args] + if (distro) return ["-d", distro, "--", ...args] + return ["--", ...args] } export function runWsl(args: string[], opts: RunWslOptions = {}) { @@ -207,60 +199,6 @@ export function runWslInDistro(args: string[], distro?: string | null, opts?: Ru return runWsl(wslArgs(args, distro), opts) } -export type WslRegistryDistro = { - name: string - defaultUid: number - state: number - version: number -} - -// Read LXSS metadata from the Windows registry. This never invokes -// wsl.exe, so it is safe to call when wsl.exe itself is wedged. -// DefaultUid === 0 on a user-oriented distro means the first-run -// "Create a default UNIX user account" step never completed. -// -// Uses a `reg query` fallback strategy because some hosts (e.g. Electron -// spawning PowerShell with certain user profiles) return nothing from the -// PowerShell registry provider; parsing `reg query` output is ugly but -// native Windows and always available. -export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise { - // `reg query` prints each subkey's values in a stable format: - // - // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{guid} - // DistributionName REG_SZ Ubuntu-24.04 - // DefaultUid REG_DWORD 0x0 - // State REG_DWORD 0x1 - // Version REG_DWORD 0x2 - // ... - const result = await runCommand( - "reg.exe", - ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss", "/s"], - opts, - ) - const stdout = result.stdout - if (result.code !== 0 || !stdout) { - return [] - } - const blocks = stdout.split(/\r?\n\r?\n/) - const out: WslRegistryDistro[] = [] - for (const block of blocks) { - const header = block.match(/^(HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\\{[^}]+\})/i) - if (!header) continue - const name = block.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/m)?.[1] - if (!name) continue - const uidHex = block.match(/^\s+DefaultUid\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" - const stateHex = block.match(/^\s+State\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" - const versionHex = block.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0" - out.push({ - name, - defaultUid: Number.parseInt(uidHex, 16), - state: Number.parseInt(stateHex, 16), - version: Number.parseInt(versionHex, 16), - }) - } - return out -} - export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { return runWslInDistro(["sh", "-lc", script], distro, opts) } @@ -366,55 +304,15 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } -async function readWslDefaultUser(distro: string, opts?: RunWslOptions) { - const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro) - if (!entry || entry.defaultUid === 0) return null - - const passwd = firstLine( - ( - await runWslSh( - [ - "if command -v getent >/dev/null 2>&1; then", - ` getent passwd ${entry.defaultUid}`, - "else", - ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`, - "fi", - ].join("\n"), - distro, - opts, - ) - ).stdout, - ) - if (!passwd) return null - - const parts = passwd.split(":") - const username = parts[0]?.trim() ?? "" - const home = parts[5]?.trim() ?? "" - if (!home) return null - return { username: username || null, home } -} - -export async function resolveWslHome(distro: string, opts?: RunWslOptions) { - return (await readWslDefaultUser(distro, opts))?.home ?? "/root" -} - -function opencodeCandidate(path: string) { - return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi` +export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) { + return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/" } export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { - const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout) - if (command && !command.startsWith("/mnt/")) return command + const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout) + if (command) return command - const home = await resolveWslHome(distro, opts) for (const candidate of [ - ...(home !== "/root" - ? [ - opencodeCandidate(`${home}/.local/bin/opencode`), - opencodeCandidate(`${home}/bin/opencode`), - opencodeCandidate(`${home}/.opencode/bin/opencode`), - ] - : []), 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi', 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi', 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', From de69c5af0aa65b20bece8a2c2b2c52ca972739b9 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 23:34:03 +1000 Subject: [PATCH 87/88] fix(app): stabilize server switching --- packages/app/src/app.tsx | 38 ++++++++++--------- .../src/components/dialog-select-server.tsx | 31 ++++++--------- .../src/components/status-popover-body.tsx | 15 ++++++-- .../app/src/components/status-popover.tsx | 2 +- packages/app/src/context/global-sync.tsx | 6 +-- .../src/context/global-sync/child-store.ts | 34 +++++++++++------ packages/app/src/utils/solid-dnd.tsx | 24 +++++------- packages/desktop-electron/src/main/index.ts | 1 + 8 files changed, 80 insertions(+), 71 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a15b65cd84ce..fd956f0afaa5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -286,11 +286,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ) } -function ServerKey(props: ParentProps) { +function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) { const server = useServer() return ( - {props.children} + {(key) => props.children(key)} ) } @@ -310,22 +310,24 @@ export function AppInterface(props: { > - - - - {routerProps.children}} - > - - - - - - - - - + {() => ( + + + + {routerProps.children}} + > + + + + + + + + + + )} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ef62315bb558..c627deed3015 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, startTransition, untrack } from "solid-js" +import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -386,28 +386,21 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const nextKey = ServerConnection.key(conn) const changed = server.key !== nextKey - const navigateHome = () => { - if (changed && typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") + const navigateHome = () => props.onNavigateHome?.() + + const apply = () => { + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) + navigateHome() return } - props.onNavigateHome?.() - } - const apply = () => - startTransition(() => { - dialog.close() - if (persist && conn.type === "http") { - server.add(conn) - navigateHome() - return - } - - batch(() => { - navigateHome() - server.setActive(nextKey) - }) + batch(() => { + navigateHome() + server.setActive(nextKey) }) + } if (!changed) { await apply() diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 952e3eac64a0..787c91a21c71 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { useNavigate } from "@solidjs/router" +import { useLocation, useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -156,13 +156,14 @@ const useMcpToggleMutation = () => { })) } -export function StatusPopoverBody(props: { shown: Accessor }) { +export function StatusPopoverBody(props: { shown: Accessor; close?: () => void }) { const sync = useSync() const server = useServer() const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const location = useLocation() const fail = (err: unknown) => { showToast({ @@ -251,8 +252,16 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return + props.close?.() navigate("/") - queueMicrotask(() => server.setActive(key)) + const activate = () => { + if (location.pathname !== "/") { + setTimeout(activate, 16) + return + } + setTimeout(() => server.setActive(key), 0) + } + setTimeout(activate, 0) }} > diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 6820a940b05a..152cddffc85e 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -58,7 +58,7 @@ export function StatusPopover() {
} > - + setShown(false)} /> diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 6190deb1ee4d..0a729a38de34 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -371,11 +371,7 @@ function createGlobalSync() { onCleanup(() => { queue.dispose() }) - onCleanup(() => { - for (const directory of Object.keys(children.children)) { - children.disposeDirectory(directoryKey(directory)) - } - }) + onCleanup(children.disposeAll) onMount(() => { if (typeof requestAnimationFrame === "function") { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cdccd..411102e26fb5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -92,6 +92,22 @@ export function createChildStoreManager(input: { }) } + function disposeChild(key: DirectoryKey) { + const dispose = disposers.get(key) + if (!key || !children[key]) return false + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + disposers.delete(key) + delete children[key] + input.onDispose(key) + if (dispose) { + dispose() + } + return true + } + function disposeDirectory(directory: DirectoryKey) { const key = directory if ( @@ -106,18 +122,13 @@ export function createChildStoreManager(input: { return false } - vcsCache.delete(key) - metaCache.delete(key) - iconCache.delete(key) - lifecycle.delete(key) - const dispose = disposers.get(key) - if (dispose) { - dispose() - disposers.delete(key) + return disposeChild(key) + } + + function disposeAll() { + for (const directory of Object.keys(children)) { + disposeChild(directoryKey(directory)) } - delete children[key] - input.onDispose(key) - return true } function runEviction(skip?: string) { @@ -329,6 +340,7 @@ export function createChildStoreManager(input: { unpin, pinned, disposeDirectory, + disposeAll, runEviction, vcsCache, metaCache, diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx index 8e30a033aecd..363db50814a9 100644 --- a/packages/app/src/utils/solid-dnd.tsx +++ b/packages/app/src/utils/solid-dnd.tsx @@ -1,6 +1,6 @@ import { useDragDropContext } from "@thisbeyond/solid-dnd" import type { Transformer } from "@thisbeyond/solid-dnd" -import { createRoot, onCleanup, type JSXElement } from "solid-js" +import type { JSXElement } from "solid-js" type DragEvent = { draggable?: { id?: unknown } } @@ -27,20 +27,16 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX if (!context) return null const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context const transformer = createTransformer(transformerId, axis) - const dispose = createRoot((dispose) => { - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return dispose + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) }) - onCleanup(dispose) return null } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index b12dcb3b1749..e0e1a37e0f51 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -87,6 +87,7 @@ setupApp() function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") if (!app.requestSingleInstanceLock()) { app.quit() From e16115f53f8508ccd632dc52f3e53659a8a6dd2c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 15:27:10 +1000 Subject: [PATCH 88/88] fix(desktop): improve WSL OpenCode updates --- packages/app/src/components/dialog-select-server.tsx | 6 +++++- packages/app/src/components/dialog-wsl-server.tsx | 7 +++++++ packages/desktop-electron/src/main/wsl.ts | 7 +++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index c627deed3015..7ee185b28991 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -5,6 +5,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" +import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" @@ -717,7 +718,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { if (wslDistro) updateWslMutation.mutate(wslDistro) }} > - {updating() ? "Updating OpenCode..." : label()} + + + + {label()} )} diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index 99ff342beb2c..b601865d3d6e 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -91,6 +91,10 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { }) const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null) const installingDistro = createMemo(() => current()?.job?.kind === "install-distro") + const installingOpencode = createMemo(() => { + const job = current()?.job + return job?.kind === "install-opencode" && job.distro === store.selectedDistro + }) const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() @@ -523,6 +527,9 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { disabled={busy()} onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))} > + + + {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 582608d3c8a6..e0ee64d8beed 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -33,9 +33,8 @@ export type RunWslOptions = { const DEFAULT_WSL_TIMEOUT_MS = 20_000 const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 -export function wslArgs(args: string[], distro?: string | null) { - if (distro) return ["-d", distro, "--", ...args] - return ["--", ...args] +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 = {}) { @@ -333,7 +332,7 @@ export async function readWslCommandVersion(command: string, distro: string, opt export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) { return runInteractiveCommand( resolveSystem32Command("wsl.exe"), - wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro), + wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro, "root"), withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, )