diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 165bf5bb..9bd41304 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -36,6 +36,10 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { RuntimeAPIs } from '@/lib/api/types'; import { TooltipProvider } from '@/components/ui/tooltip'; +const CLI_MISSING_ERROR_REGEX = + /ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i; +const CLI_ONBOARDING_HEALTH_POLL_MS = 1500; + const AboutDialogWrapper: React.FC = () => { const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore(); return ( @@ -237,20 +241,33 @@ function App({ apis }: AppProps) { let cancelled = false; const run = async () => { const res = await fetch('/health', { method: 'GET' }).catch(() => null); - if (!res || !res.ok) return; - const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown }; + if (!res || !res.ok || cancelled) return; + const data = (await res.json().catch(() => null)) as null | { + openCodeRunning?: unknown; + isOpenCodeReady?: unknown; + opencodeBinaryResolved?: unknown; + lastOpenCodeError?: unknown; + }; if (!data || cancelled) return; const openCodeRunning = data.openCodeRunning === true; + const isOpenCodeReady = data.isOpenCodeReady === true; + const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : ''; + const hasResolvedBinary = resolvedBinary.length > 0; const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : ''; const cliMissing = !openCodeRunning && - /ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|env:\s*(node|bun):\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i.test(err); + (CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady)); setShowCliOnboarding(cliMissing); }; void run(); + const interval = window.setInterval(() => { + void run(); + }, CLI_ONBOARDING_HEALTH_POLL_MS); + return () => { cancelled = true; + window.clearInterval(interval); }; }, []); diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx index 59b31f9b..0d7f23ae 100644 --- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx +++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx @@ -8,6 +8,10 @@ import { copyTextToClipboard } from '@/lib/clipboard'; const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; const POLL_INTERVAL_MS = 3000; +const DOCS_URL = 'https://opencode.ai/docs'; +const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl'; + +type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown'; type OnboardingScreenProps = { onCliAvailable?: () => void; @@ -42,6 +46,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { const [isDesktopApp, setIsDesktopApp] = React.useState(false); const [isRetrying, setIsRetrying] = React.useState(false); const [opencodeBinary, setOpencodeBinary] = React.useState(''); + const [platform, setPlatform] = React.useState('unknown'); React.useEffect(() => { const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS); @@ -52,6 +57,28 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { setIsDesktopApp(isDesktopShell()); }, []); + React.useEffect(() => { + if (typeof navigator === 'undefined') { + setPlatform('unknown'); + return; + } + + const ua = navigator.userAgent || ''; + if (/Windows/i.test(ua)) { + setPlatform('windows'); + return; + } + if (/Macintosh|Mac OS X/i.test(ua)) { + setPlatform('macos'); + return; + } + if (/Linux/i.test(ua)) { + setPlatform('linux'); + return; + } + setPlatform('unknown'); + }, []); + React.useEffect(() => { let cancelled = false; void (async () => { @@ -170,6 +197,14 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { return () => clearInterval(interval); }, [checkCliAvailability, onCliAvailable]); + const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL; + const binaryPlaceholder = + platform === 'windows' + ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd' + : platform === 'linux' + ? '/home/you/.bun/bin/opencode' + : '/Users/you/.bun/bin/opencode'; + return (
+ {platform === 'windows' && ( +
+
Windows setup (WSL recommended)
+
    +
  1. Install WSL (if needed) with wsl --install in PowerShell.
  2. +
  3. Run the install command below inside your WSL terminal.
  4. +
  5. If OpenChamber does not detect OpenCode automatically, set the binary path below.
  6. +
+
+ )} +
{copied ? ( @@ -208,12 +254,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
- View documentation + {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'} @@ -239,7 +285,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { setOpencodeBinary(e.target.value)} - placeholder="/Users/you/.bun/bin/opencode" + placeholder={binaryPlaceholder} disabled={isRetrying} className="flex-1 font-mono text-xs" /> @@ -259,24 +305,35 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { Apply
-
- Saves to ~/.config/openchamber/settings.json and reloads OpenCode configuration. -
+
Saves to OpenChamber settings and reloads OpenCode configuration.
{showHint && (
-

- Already installed? Make sure opencode is in your PATH -

-

- or set OPENCODE_BINARY environment variable. -

-

- If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. -

+ {platform === 'windows' ? ( + <> +

+ On Windows, install and run OpenCode in WSL for best compatibility. +

+

+ If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode. +

+ + ) : ( + <> +

+ Already installed? Make sure opencode is in your PATH +

+

+ or set OPENCODE_BINARY environment variable. +

+

+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. +

+ + )}
)} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 88a28b64..dac75685 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -166,9 +166,49 @@ const normalizeOrigin = (raw: string): string | null => { } }; +const parseUrl = (raw: string): URL | null => { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return new URL(trimmed); + } catch { + try { + return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); + } catch { + return null; + } + } +}; + +const normalizeHost = (rawHost: string): string => rawHost.replace(/^\[|\]$/g, '').toLowerCase(); + +const isLoopbackHost = (host: string): boolean => { + const normalized = normalizeHost(host); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +}; + export const isDesktopLocalOriginActive = (): boolean => { if (typeof window === 'undefined') return false; const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; + const localUrl = parseUrl(local); + const currentUrl = parseUrl(window.location.origin); + + if (localUrl && currentUrl) { + if (localUrl.origin === currentUrl.origin) { + return true; + } + + const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80'); + const currentPort = currentUrl.port || (currentUrl.protocol === 'https:' ? '443' : '80'); + + return ( + localUrl.protocol === currentUrl.protocol && + localPort === currentPort && + isLoopbackHost(localUrl.hostname) && + isLoopbackHost(currentUrl.hostname) + ); + } + const localOrigin = normalizeOrigin(local); const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin; return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin); diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 65f920af..36b7e8b5 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -108,6 +108,16 @@ function isExecutable(filePath: string): boolean { } } +function shouldUseWindowsShell(binary: string): boolean { + if (process.platform !== 'win32') return false; + const trimmed = (binary || '').trim(); + if (!trimmed) return true; + const ext = path.extname(trimmed).toLowerCase(); + if (ext === '.cmd' || ext === '.bat') return true; + // Bare command names often resolve to .cmd shims via PATHEXT. + return !ext && !trimmed.includes('\\') && !trimmed.includes('/'); +} + function appendToPath(dir: string) { const trimmed = (dir || '').trim(); if (!trimmed) return; @@ -349,6 +359,7 @@ async function spawnManagedOpenCodeServer( cwd: workingDirectory, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'], + shell: shouldUseWindowsShell(binary), }); const url = await new Promise((resolve, reject) => { diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index fbc9a97f..79109ca4 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -4,7 +4,7 @@ import path from 'path'; import fs from 'fs'; import net from 'net'; import { spawn, spawnSync } from 'child_process'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -625,7 +625,7 @@ const commands = { return; } - const { startWebUiServer } = await import(serverPath); + const { startWebUiServer } = await import(pathToFileURL(serverPath).href); await startWebUiServer({ port: options.port, attachSignals: true, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index dd1f34f7..becf3d5a 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -3398,6 +3398,15 @@ const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_ const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; +const ENV_CONFIGURED_OPENCODE_WSL_DISTRO = + typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0 + ? process.env.OPENCODE_WSL_DISTRO.trim() + : ( + typeof process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO === 'string' && + process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim().length > 0 + ? process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim() + : null + ); // OpenCode server authentication (Basic Auth with username "opencode") @@ -3644,6 +3653,10 @@ let resolvedOpencodeBinary = null; let resolvedOpencodeBinarySource = null; let resolvedNodeBinary = null; let resolvedBunBinary = null; +let useWslForOpencode = false; +let resolvedWslBinary = null; +let resolvedWslOpencodePath = null; +let resolvedWslDistro = null; function isExecutable(filePath) { try { @@ -3682,6 +3695,136 @@ function searchPathFor(binaryName) { return null; } +function isWslExecutableValue(value) { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed) return false; + return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); +} + +function clearWslOpencodeResolution() { + useWslForOpencode = false; + resolvedWslBinary = null; + resolvedWslOpencodePath = null; + resolvedWslDistro = null; +} + +function resolveWslExecutablePath() { + if (process.platform !== 'win32') { + return null; + } + + const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + try { + const result = spawnSync('where', ['wsl'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) { + return found; + } + } + } catch { + // ignore + } + + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + const fallback = path.join(systemRoot, 'System32', 'wsl.exe'); + if (isExecutable(fallback)) { + return fallback; + } + + return null; +} + +function buildWslExecArgs(execArgs, distroOverride = null) { + const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0 + ? distroOverride.trim() + : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + + const prefix = distro ? ['-d', distro] : []; + return [...prefix, '--exec', ...execArgs]; +} + +function probeWslForOpencode() { + if (process.platform !== 'win32') { + return null; + } + + const wslBinary = resolveWslExecutablePath(); + if (!wslBinary) { + return null; + } + + try { + const result = spawnSync( + wslBinary, + buildWslExecArgs(['sh', '-lc', 'command -v opencode']), + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 6000, + }, + ); + + if (result.status !== 0) { + return null; + } + + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines[0] || ''; + if (!found) { + return null; + } + + return { + wslBinary, + opencodePath: found, + distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }; + } catch { + return null; + } +} + +function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) { + const resolvedWsl = wslBinary || resolveWslExecutablePath(); + if (!resolvedWsl) { + return null; + } + + useWslForOpencode = true; + resolvedWslBinary = resolvedWsl; + resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0 + ? opencodePath.trim() + : 'opencode'; + resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`; + resolvedOpencodeBinarySource = source; + + // Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts. + delete process.env.OPENCODE_BINARY; + return resolvedOpencodeBinary; +} + function resolveOpencodeCliPath() { const explicit = [ process.env.OPENCODE_BINARY, @@ -3694,6 +3837,7 @@ function resolveOpencodeCliPath() { for (const candidate of explicit) { if (isExecutable(candidate)) { + clearWslOpencodeResolution(); resolvedOpencodeBinarySource = 'env'; return candidate; } @@ -3701,6 +3845,7 @@ function resolveOpencodeCliPath() { const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { + clearWslOpencodeResolution(); resolvedOpencodeBinarySource = 'path'; return resolvedFromPath; } @@ -3739,6 +3884,7 @@ function resolveOpencodeCliPath() { const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { if (isExecutable(candidate)) { + clearWslOpencodeResolution(); resolvedOpencodeBinarySource = 'fallback'; return candidate; } @@ -3757,6 +3903,7 @@ function resolveOpencodeCliPath() { .filter(Boolean); const found = lines.find((line) => isExecutable(line)); if (found) { + clearWslOpencodeResolution(); resolvedOpencodeBinarySource = 'where'; return found; } @@ -3764,6 +3911,15 @@ function resolveOpencodeCliPath() { } catch { // ignore } + const wsl = probeWslForOpencode(); + if (wsl) { + return applyWslOpencodeResolution({ + wslBinary: wsl.wslBinary, + opencodePath: wsl.opencodePath, + source: 'wsl', + distro: wsl.distro, + }); + } return null; } @@ -3778,6 +3934,7 @@ function resolveOpencodeCliPath() { if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; if (found && isExecutable(found)) { + clearWslOpencodeResolution(); resolvedOpencodeBinarySource = 'shell'; return found; } @@ -4059,10 +4216,44 @@ async function applyOpencodeBinaryFromSettings() { delete process.env.OPENCODE_BINARY; resolvedOpencodeBinary = null; resolvedOpencodeBinarySource = null; + clearWslOpencodeResolution(); return null; } + const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; + + const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' + ? raw.match(/^wsl:\s*(.+)$/i) + : null; + + if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || resolveWslExecutablePath(), + opencodePath: explicitWslPath[1].trim(), + source: 'settings-wsl-path', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + + if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || normalized || raw || null, + opencodePath: probe?.opencodePath || 'opencode', + source: 'settings-wsl', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + if (normalized && isExecutable(normalized)) { + clearWslOpencodeResolution(); process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); resolvedOpencodeBinary = normalized; @@ -4071,7 +4262,6 @@ async function applyOpencodeBinaryFromSettings() { return normalized; } - const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; if (raw) { console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); } @@ -4084,12 +4274,16 @@ async function applyOpencodeBinaryFromSettings() { function ensureOpencodeCliEnv() { if (resolvedOpencodeBinary) { + if (useWslForOpencode) { + return resolvedOpencodeBinary; + } ensureOpencodeShimRuntime(resolvedOpencodeBinary); return resolvedOpencodeBinary; } const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; if (existing && isExecutable(existing)) { + clearWslOpencodeResolution(); resolvedOpencodeBinary = existing; resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env'; prependToPath(path.dirname(existing)); @@ -4099,6 +4293,13 @@ function ensureOpencodeCliEnv() { const resolved = resolveOpencodeCliPath(); if (resolved) { + if (useWslForOpencode) { + resolvedOpencodeBinary = resolved; + resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl'; + console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`); + return resolved; + } + process.env.OPENCODE_BINARY = resolved; prependToPath(path.dirname(resolved)); ensureOpencodeShimRuntime(resolved); @@ -4108,6 +4309,7 @@ function ensureOpencodeCliEnv() { return resolved; } + clearWslOpencodeResolution(); return null; } @@ -5171,12 +5373,34 @@ async function createManagedOpenCodeServerProcess({ env, }) { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; - const args = ['serve', '--hostname', hostname, '--port', String(port)]; + let args = ['serve', '--hostname', hostname, '--port', String(port)]; + + if (process.platform === 'win32' && useWslForOpencode) { + const wslBinary = resolvedWslBinary || resolveWslExecutablePath(); + if (!wslBinary) { + throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); + } + + const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0 + ? resolvedWslOpencodePath.trim() + : 'opencode'; + const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; + + binary = wslBinary; + args = buildWslExecArgs([ + wslOpencode, + 'serve', + '--hostname', + serveHost, + '--port', + String(port), + ], resolvedWslDistro); + } // On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh). // Detect if the resolved binary is a shim that wraps a Node/Bun script and // resolve the actual target so we can spawn it with the correct interpreter. - if (process.platform === 'win32') { + if (process.platform === 'win32' && !useWslForOpencode) { const interpreter = opencodeShimInterpreter(binary); if (interpreter) { // Binary itself has a node/bun shebang – spawn via that interpreter. @@ -5675,29 +5899,53 @@ function setupProxy(app) { } app.set('opencodeProxyConfigured', true); - // Windows path normalization: OpenCode CLI stores paths with backslashes in the DB, - // but the frontend sends forward slashes. Rewrite directory query params on Windows. - // Must run BEFORE all other /api middleware. - if (process.platform === 'win32') { - app.use('/api', (req, _res, next) => { - // Parse directory from the raw URL since Express query parsing may not be available - const rawUrl = req.originalUrl || req.url || ''; - const dirMatch = rawUrl.match(/[?&]directory=([^&]*)/); - if (dirMatch) { - const decoded = decodeURIComponent(dirMatch[1]); - if (decoded.includes('/')) { - const fixed = decoded.replace(/\//g, '\\'); - const fixedEncoded = encodeURIComponent(fixed); - const newUrl = rawUrl.replace(/([?&]directory=)[^&]*/, '$1' + fixedEncoded); - console.log(`[Win32PathFix] Rewrote directory: "${decoded}" → "${fixed}"`); - console.log(`[Win32PathFix] URL: "${rawUrl}" → "${newUrl}"`); - req.originalUrl = newUrl; - req.url = newUrl; - } + const stripApiPrefix = (rawUrl) => { + if (typeof rawUrl !== 'string' || !rawUrl) { + return '/'; + } + if (rawUrl === '/api') { + return '/'; + } + if (rawUrl.startsWith('/api/')) { + return rawUrl.slice(4); + } + return rawUrl; + }; + + // Keep route matching stable; only rewrite the proxied upstream path. + const rewriteWindowsDirectoryParam = (upstreamPath) => { + if (process.platform !== 'win32') { + return upstreamPath; + } + try { + const parsed = new URL(upstreamPath, 'http://openchamber.local'); + const pathname = parsed.pathname || '/'; + if (pathname === '/session' || pathname.startsWith('/session/')) { + return upstreamPath; } - next(); - }); - } + const directory = parsed.searchParams.get('directory'); + if (!directory || !directory.includes('/')) { + return upstreamPath; + } + const fixed = directory.replace(/\//g, '\\'); + parsed.searchParams.set('directory', fixed); + const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`; + if (rewritten !== upstreamPath) { + console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`); + console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`); + } + return rewritten; + } catch { + return upstreamPath; + } + }; + + const getUpstreamPathForRequest = (req) => { + const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl) + ? req.originalUrl + : (typeof req.url === 'string' ? req.url : '/'); + return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl)); + }; app.use('/api', (req, res, next) => { if ( @@ -5733,7 +5981,7 @@ function setupProxy(app) { const forwardSseRequest = async (req, res) => { const startedAt = Date.now(); - const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const upstreamPath = getUpstreamPathForRequest(req); const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const authHeaders = getOpenCodeAuthHeaders(); @@ -5970,7 +6218,7 @@ function setupProxy(app) { const forwardGenericApiRequest = async (req, res) => { try { - const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const upstreamPath = getUpstreamPathForRequest(req); const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const headers = collectForwardHeaders(req); const method = String(req.method || 'GET').toUpperCase(); @@ -6008,7 +6256,7 @@ function setupProxy(app) { // This avoids edge-cases in generic proxy streaming for multi-file attachments. app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { try { - const upstreamPath = req.originalUrl.replace(/^\/api/, ''); + const upstreamPath = getUpstreamPathForRequest(req); const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const authHeaders = getOpenCodeAuthHeaders(); @@ -6067,7 +6315,8 @@ function setupProxy(app) { signal: AbortSignal.timeout(10000), }; const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); - const globalSessions = globalRes.ok ? (await globalRes.json()) : []; + const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : []; + const globalSessions = Array.isArray(globalPayload) ? globalPayload : []; const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); let projectDirs = []; @@ -6075,33 +6324,47 @@ function setupProxy(app) { const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); const settings = JSON.parse(settingsRaw); projectDirs = (settings.projects || []) - .map(p => p.path) - .filter(p => typeof p === 'string' && p.length > 0); + .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) + .filter(Boolean); } catch {} - const seen = new Set(globalSessions.map(s => s.id)); + const seen = new Set( + globalSessions + .map((session) => (session && typeof session.id === 'string' ? session.id : null)) + .filter((id) => typeof id === 'string') + ); const extraSessions = []; for (const dir of projectDirs) { - const backslashDir = dir.replace(/\//g, '\\'); - const encoded = encodeURIComponent(backslashDir); - try { - const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); - if (dirRes.ok) { - const dirSessions = await dirRes.json(); - if (Array.isArray(dirSessions)) { - for (const s of dirSessions) { - if (s && s.id && !seen.has(s.id)) { - seen.add(s.id); - extraSessions.push(s); + const candidates = Array.from(new Set([ + dir, + dir.replace(/\\/g, '/'), + dir.replace(/\//g, '\\'), + ])); + for (const candidateDir of candidates) { + const encoded = encodeURIComponent(candidateDir); + try { + const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); + if (dirRes.ok) { + const dirPayload = await dirRes.json().catch(() => []); + const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; + for (const session of dirSessions) { + const id = session && typeof session.id === 'string' ? session.id : null; + if (id && !seen.has(id)) { + seen.add(id); + extraSessions.push(session); } } } - } - } catch {} + } catch {} + } } const merged = [...globalSessions, ...extraSessions]; - merged.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0)); + merged.sort((a, b) => { + const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; + const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; + return bTime - aTime; + }); console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); return res.json(merged); } catch (error) { @@ -6310,6 +6573,10 @@ async function main(options = {}) { opencodeBinaryResolved: resolvedOpencodeBinary || null, opencodeBinarySource: resolvedOpencodeBinarySource || null, opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null, + opencodeViaWsl: useWslForOpencode, + opencodeWslBinary: resolvedWslBinary || null, + opencodeWslPath: resolvedWslOpencodePath || null, + opencodeWslDistro: resolvedWslDistro || null, nodeBinaryResolved: resolvedNodeBinary || null, bunBinaryResolved: resolvedBunBinary || null, }); @@ -7687,6 +7954,10 @@ async function main(options = {}) { detectedNow, detectedSourceNow, shim, + viaWsl: useWslForOpencode, + wslBinary: resolvedWslBinary || null, + wslPath: resolvedWslOpencodePath || null, + wslDistro: resolvedWslDistro || null, node: resolvedNodeBinary || null, bun: resolvedBunBinary || null, });