Fix Windows path/spawn regressions and session visibility (#552)

* Update OpenCode CLI detection

* fix(windows): handle cli file-url import and cmd shim spawn

Fixes #533 and #521.

* fix(windows): stabilize api path rewrite and session merge

Fixes #548.
This commit is contained in:
Bohdan Triapitsyn
2026-02-28 16:00:10 +02:00
committed by GitHub
parent 122a45a890
commit 6eb5d1afa9
6 changed files with 462 additions and 66 deletions
+20 -3
View File
@@ -36,6 +36,10 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { RuntimeAPIs } from '@/lib/api/types'; import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip'; 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 AboutDialogWrapper: React.FC = () => {
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore(); const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
return ( return (
@@ -237,20 +241,33 @@ function App({ apis }: AppProps) {
let cancelled = false; let cancelled = false;
const run = async () => { const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null); const res = await fetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok) return; if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown }; const data = (await res.json().catch(() => null)) as null | {
openCodeRunning?: unknown;
isOpenCodeReady?: unknown;
opencodeBinaryResolved?: unknown;
lastOpenCodeError?: unknown;
};
if (!data || cancelled) return; if (!data || cancelled) return;
const openCodeRunning = data.openCodeRunning === true; 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 err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
const cliMissing = const cliMissing =
!openCodeRunning && !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); setShowCliOnboarding(cliMissing);
}; };
void run(); void run();
const interval = window.setInterval(() => {
void run();
}, CLI_ONBOARDING_HEALTH_POLL_MS);
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(interval);
}; };
}, []); }, []);
@@ -8,6 +8,10 @@ import { copyTextToClipboard } from '@/lib/clipboard';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const POLL_INTERVAL_MS = 3000; 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 = { type OnboardingScreenProps = {
onCliAvailable?: () => void; onCliAvailable?: () => void;
@@ -42,6 +46,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
const [isDesktopApp, setIsDesktopApp] = React.useState(false); const [isDesktopApp, setIsDesktopApp] = React.useState(false);
const [isRetrying, setIsRetrying] = React.useState(false); const [isRetrying, setIsRetrying] = React.useState(false);
const [opencodeBinary, setOpencodeBinary] = React.useState(''); const [opencodeBinary, setOpencodeBinary] = React.useState('');
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
React.useEffect(() => { React.useEffect(() => {
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS); const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
@@ -52,6 +57,28 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
setIsDesktopApp(isDesktopShell()); 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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -170,6 +197,14 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [checkCliAvailability, onCliAvailable]); }, [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 ( return (
<div <div
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none" className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
@@ -194,6 +229,17 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
</p> </p>
</div> </div>
{platform === 'windows' && (
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
<li>Run the install command below inside your WSL terminal.</li>
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
</ol>
</div>
)}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit"> <div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
{copied ? ( {copied ? (
@@ -208,12 +254,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
</div> </div>
<a <a
href="https://opencode.ai/docs" href={docsUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center" className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
> >
View documentation {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
<RiExternalLinkLine className="h-3 w-3" /> <RiExternalLinkLine className="h-3 w-3" />
</a> </a>
@@ -239,7 +285,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
<Input <Input
value={opencodeBinary} value={opencodeBinary}
onChange={(e) => setOpencodeBinary(e.target.value)} onChange={(e) => setOpencodeBinary(e.target.value)}
placeholder="/Users/you/.bun/bin/opencode" placeholder={binaryPlaceholder}
disabled={isRetrying} disabled={isRetrying}
className="flex-1 font-mono text-xs" className="flex-1 font-mono text-xs"
/> />
@@ -259,24 +305,35 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
Apply Apply
</Button> </Button>
</div> </div>
<div className="text-xs text-muted-foreground/70"> <div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
Saves to <code className="text-foreground/70">~/.config/openchamber/settings.json</code> and reloads OpenCode configuration.
</div>
</div> </div>
</div> </div>
</div> </div>
{showHint && ( {showHint && (
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1"> <div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
<p className="text-sm text-muted-foreground/70"> {platform === 'windows' ? (
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH <>
</p> <p className="text-sm text-muted-foreground/70">
<p className="text-sm text-muted-foreground/70"> On Windows, install and run OpenCode in WSL for best compatibility.
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable. </p>
</p> <p className="text-sm text-muted-foreground/70">
<p className="text-sm text-muted-foreground/70"> If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH. </p>
</p> </>
) : (
<>
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
</p>
</>
)}
</div> </div>
)} )}
</div> </div>
+40
View File
@@ -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 => { export const isDesktopLocalOriginActive = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; 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 localOrigin = normalizeOrigin(local);
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin; const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin); return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin);
+11
View File
@@ -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) { function appendToPath(dir: string) {
const trimmed = (dir || '').trim(); const trimmed = (dir || '').trim();
if (!trimmed) return; if (!trimmed) return;
@@ -349,6 +359,7 @@ async function spawnManagedOpenCodeServer(
cwd: workingDirectory, cwd: workingDirectory,
env: { ...process.env }, env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
shell: shouldUseWindowsShell(binary),
}); });
const url = await new Promise<string>((resolve, reject) => { const url = await new Promise<string>((resolve, reject) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import net from 'net'; import net from 'net';
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
import { fileURLToPath } from 'url'; import { fileURLToPath, pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -625,7 +625,7 @@ const commands = {
return; return;
} }
const { startWebUiServer } = await import(serverPath); const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
await startWebUiServer({ await startWebUiServer({
port: options.port, port: options.port,
attachSignals: true, attachSignals: true,
+317 -46
View File
@@ -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' || const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === '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") // OpenCode server authentication (Basic Auth with username "opencode")
@@ -3644,6 +3653,10 @@ let resolvedOpencodeBinary = null;
let resolvedOpencodeBinarySource = null; let resolvedOpencodeBinarySource = null;
let resolvedNodeBinary = null; let resolvedNodeBinary = null;
let resolvedBunBinary = null; let resolvedBunBinary = null;
let useWslForOpencode = false;
let resolvedWslBinary = null;
let resolvedWslOpencodePath = null;
let resolvedWslDistro = null;
function isExecutable(filePath) { function isExecutable(filePath) {
try { try {
@@ -3682,6 +3695,136 @@ function searchPathFor(binaryName) {
return null; 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() { function resolveOpencodeCliPath() {
const explicit = [ const explicit = [
process.env.OPENCODE_BINARY, process.env.OPENCODE_BINARY,
@@ -3694,6 +3837,7 @@ function resolveOpencodeCliPath() {
for (const candidate of explicit) { for (const candidate of explicit) {
if (isExecutable(candidate)) { if (isExecutable(candidate)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'env'; resolvedOpencodeBinarySource = 'env';
return candidate; return candidate;
} }
@@ -3701,6 +3845,7 @@ function resolveOpencodeCliPath() {
const resolvedFromPath = searchPathFor('opencode'); const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) { if (resolvedFromPath) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'path'; resolvedOpencodeBinarySource = 'path';
return resolvedFromPath; return resolvedFromPath;
} }
@@ -3739,6 +3884,7 @@ function resolveOpencodeCliPath() {
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) { for (const candidate of fallbacks) {
if (isExecutable(candidate)) { if (isExecutable(candidate)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'fallback'; resolvedOpencodeBinarySource = 'fallback';
return candidate; return candidate;
} }
@@ -3757,6 +3903,7 @@ function resolveOpencodeCliPath() {
.filter(Boolean); .filter(Boolean);
const found = lines.find((line) => isExecutable(line)); const found = lines.find((line) => isExecutable(line));
if (found) { if (found) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'where'; resolvedOpencodeBinarySource = 'where';
return found; return found;
} }
@@ -3764,6 +3911,15 @@ function resolveOpencodeCliPath() {
} catch { } catch {
// ignore // ignore
} }
const wsl = probeWslForOpencode();
if (wsl) {
return applyWslOpencodeResolution({
wslBinary: wsl.wslBinary,
opencodePath: wsl.opencodePath,
source: 'wsl',
distro: wsl.distro,
});
}
return null; return null;
} }
@@ -3778,6 +3934,7 @@ function resolveOpencodeCliPath() {
if (result.status === 0) { if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) { if (found && isExecutable(found)) {
clearWslOpencodeResolution();
resolvedOpencodeBinarySource = 'shell'; resolvedOpencodeBinarySource = 'shell';
return found; return found;
} }
@@ -4059,10 +4216,44 @@ async function applyOpencodeBinaryFromSettings() {
delete process.env.OPENCODE_BINARY; delete process.env.OPENCODE_BINARY;
resolvedOpencodeBinary = null; resolvedOpencodeBinary = null;
resolvedOpencodeBinarySource = null; resolvedOpencodeBinarySource = null;
clearWslOpencodeResolution();
return null; 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)) { if (normalized && isExecutable(normalized)) {
clearWslOpencodeResolution();
process.env.OPENCODE_BINARY = normalized; process.env.OPENCODE_BINARY = normalized;
prependToPath(path.dirname(normalized)); prependToPath(path.dirname(normalized));
resolvedOpencodeBinary = normalized; resolvedOpencodeBinary = normalized;
@@ -4071,7 +4262,6 @@ async function applyOpencodeBinaryFromSettings() {
return normalized; return normalized;
} }
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
if (raw) { if (raw) {
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
} }
@@ -4084,12 +4274,16 @@ async function applyOpencodeBinaryFromSettings() {
function ensureOpencodeCliEnv() { function ensureOpencodeCliEnv() {
if (resolvedOpencodeBinary) { if (resolvedOpencodeBinary) {
if (useWslForOpencode) {
return resolvedOpencodeBinary;
}
ensureOpencodeShimRuntime(resolvedOpencodeBinary); ensureOpencodeShimRuntime(resolvedOpencodeBinary);
return resolvedOpencodeBinary; return resolvedOpencodeBinary;
} }
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
if (existing && isExecutable(existing)) { if (existing && isExecutable(existing)) {
clearWslOpencodeResolution();
resolvedOpencodeBinary = existing; resolvedOpencodeBinary = existing;
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env'; resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env';
prependToPath(path.dirname(existing)); prependToPath(path.dirname(existing));
@@ -4099,6 +4293,13 @@ function ensureOpencodeCliEnv() {
const resolved = resolveOpencodeCliPath(); const resolved = resolveOpencodeCliPath();
if (resolved) { 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; process.env.OPENCODE_BINARY = resolved;
prependToPath(path.dirname(resolved)); prependToPath(path.dirname(resolved));
ensureOpencodeShimRuntime(resolved); ensureOpencodeShimRuntime(resolved);
@@ -4108,6 +4309,7 @@ function ensureOpencodeCliEnv() {
return resolved; return resolved;
} }
clearWslOpencodeResolution();
return null; return null;
} }
@@ -5171,12 +5373,34 @@ async function createManagedOpenCodeServerProcess({
env, env,
}) { }) {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; 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). // 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 // 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. // 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); const interpreter = opencodeShimInterpreter(binary);
if (interpreter) { if (interpreter) {
// Binary itself has a node/bun shebang – spawn via that interpreter. // Binary itself has a node/bun shebang – spawn via that interpreter.
@@ -5675,29 +5899,53 @@ function setupProxy(app) {
} }
app.set('opencodeProxyConfigured', true); app.set('opencodeProxyConfigured', true);
// Windows path normalization: OpenCode CLI stores paths with backslashes in the DB, const stripApiPrefix = (rawUrl) => {
// but the frontend sends forward slashes. Rewrite directory query params on Windows. if (typeof rawUrl !== 'string' || !rawUrl) {
// Must run BEFORE all other /api middleware. return '/';
if (process.platform === 'win32') { }
app.use('/api', (req, _res, next) => { if (rawUrl === '/api') {
// Parse directory from the raw URL since Express query parsing may not be available return '/';
const rawUrl = req.originalUrl || req.url || ''; }
const dirMatch = rawUrl.match(/[?&]directory=([^&]*)/); if (rawUrl.startsWith('/api/')) {
if (dirMatch) { return rawUrl.slice(4);
const decoded = decodeURIComponent(dirMatch[1]); }
if (decoded.includes('/')) { return rawUrl;
const fixed = decoded.replace(/\//g, '\\'); };
const fixedEncoded = encodeURIComponent(fixed);
const newUrl = rawUrl.replace(/([?&]directory=)[^&]*/, '$1' + fixedEncoded); // Keep route matching stable; only rewrite the proxied upstream path.
console.log(`[Win32PathFix] Rewrote directory: "${decoded}" → "${fixed}"`); const rewriteWindowsDirectoryParam = (upstreamPath) => {
console.log(`[Win32PathFix] URL: "${rawUrl}" → "${newUrl}"`); if (process.platform !== 'win32') {
req.originalUrl = newUrl; return upstreamPath;
req.url = newUrl; }
} 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) => { app.use('/api', (req, res, next) => {
if ( if (
@@ -5733,7 +5981,7 @@ function setupProxy(app) {
const forwardSseRequest = async (req, res) => { const forwardSseRequest = async (req, res) => {
const startedAt = Date.now(); const startedAt = Date.now();
const upstreamPath = req.originalUrl.replace(/^\/api/, ''); const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders(); const authHeaders = getOpenCodeAuthHeaders();
@@ -5970,7 +6218,7 @@ function setupProxy(app) {
const forwardGenericApiRequest = async (req, res) => { const forwardGenericApiRequest = async (req, res) => {
try { try {
const upstreamPath = req.originalUrl.replace(/^\/api/, ''); const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const headers = collectForwardHeaders(req); const headers = collectForwardHeaders(req);
const method = String(req.method || 'GET').toUpperCase(); 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. // 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) => { app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
try { try {
const upstreamPath = req.originalUrl.replace(/^\/api/, ''); const upstreamPath = getUpstreamPathForRequest(req);
const targetUrl = buildOpenCodeUrl(upstreamPath, ''); const targetUrl = buildOpenCodeUrl(upstreamPath, '');
const authHeaders = getOpenCodeAuthHeaders(); const authHeaders = getOpenCodeAuthHeaders();
@@ -6067,7 +6315,8 @@ function setupProxy(app) {
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}; };
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); 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'); const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
let projectDirs = []; let projectDirs = [];
@@ -6075,33 +6324,47 @@ function setupProxy(app) {
const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsRaw); const settings = JSON.parse(settingsRaw);
projectDirs = (settings.projects || []) projectDirs = (settings.projects || [])
.map(p => p.path) .map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
.filter(p => typeof p === 'string' && p.length > 0); .filter(Boolean);
} catch {} } 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 = []; const extraSessions = [];
for (const dir of projectDirs) { for (const dir of projectDirs) {
const backslashDir = dir.replace(/\//g, '\\'); const candidates = Array.from(new Set([
const encoded = encodeURIComponent(backslashDir); dir,
try { dir.replace(/\\/g, '/'),
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); dir.replace(/\//g, '\\'),
if (dirRes.ok) { ]));
const dirSessions = await dirRes.json(); for (const candidateDir of candidates) {
if (Array.isArray(dirSessions)) { const encoded = encodeURIComponent(candidateDir);
for (const s of dirSessions) { try {
if (s && s.id && !seen.has(s.id)) { const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
seen.add(s.id); if (dirRes.ok) {
extraSessions.push(s); 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]; 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`); console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(merged); return res.json(merged);
} catch (error) { } catch (error) {
@@ -6310,6 +6573,10 @@ async function main(options = {}) {
opencodeBinaryResolved: resolvedOpencodeBinary || null, opencodeBinaryResolved: resolvedOpencodeBinary || null,
opencodeBinarySource: resolvedOpencodeBinarySource || null, opencodeBinarySource: resolvedOpencodeBinarySource || null,
opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null, opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null,
opencodeViaWsl: useWslForOpencode,
opencodeWslBinary: resolvedWslBinary || null,
opencodeWslPath: resolvedWslOpencodePath || null,
opencodeWslDistro: resolvedWslDistro || null,
nodeBinaryResolved: resolvedNodeBinary || null, nodeBinaryResolved: resolvedNodeBinary || null,
bunBinaryResolved: resolvedBunBinary || null, bunBinaryResolved: resolvedBunBinary || null,
}); });
@@ -7687,6 +7954,10 @@ async function main(options = {}) {
detectedNow, detectedNow,
detectedSourceNow, detectedSourceNow,
shim, shim,
viaWsl: useWslForOpencode,
wslBinary: resolvedWslBinary || null,
wslPath: resolvedWslOpencodePath || null,
wslDistro: resolvedWslDistro || null,
node: resolvedNodeBinary || null, node: resolvedNodeBinary || null,
bun: resolvedBunBinary || null, bun: resolvedBunBinary || null,
}); });