From fd08cc4e5d91bff74195e3ea333d94b600578bf1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 6 Feb 2026 02:50:04 +0200 Subject: [PATCH] fix(desktop): terminate sidecar on main window close - Terminate the sidecar when the macOS main window is closed - Update opencode-ai/sdk to v1.1.53 across all packages --- bun.lock | 8 +- package.json | 2 +- packages/desktop/src-tauri/src/main.rs | 8 ++ packages/ui/package.json | 2 +- packages/ui/src/lib/openCodeStatus.ts | 157 +++++++++++++++++++++++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- packages/web/server/index.js | 67 ++++++++++- 8 files changed, 238 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 54022f6c..b468d631 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -135,7 +135,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -202,7 +202,7 @@ "version": "1.6.4", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -231,7 +231,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/package.json b/package.json index ecd7883e..82bf876a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 2827de72..bc00c4aa 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1482,6 +1482,14 @@ fn main() { *state.focused.lock().expect("focus mutex") = *focused; } } + + #[cfg(target_os = "macos")] + if let tauri::WindowEvent::CloseRequested { .. } = event { + // Ensure sidecar is not left running when users close the main window. + let app = window.app_handle(); + kill_sidecar(app.clone()); + app.exit(0); + } }) .invoke_handler(tauri::generate_handler![ desktop_notify, diff --git a/packages/ui/package.json b/packages/ui/package.json index 7fc45ce6..9e8f9ed9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 2579ad18..959cce49 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -10,6 +10,30 @@ type ProbeResult = { summary: string; }; +type OpenChamberHealthSnapshot = { + openCodePort?: unknown; + openCodeRunning?: unknown; + isOpenCodeReady?: unknown; + lastOpenCodeError?: unknown; + opencodeBinaryResolved?: unknown; + opencodeBinarySource?: unknown; + opencodeShimInterpreter?: unknown; + nodeBinaryResolved?: unknown; + bunBinaryResolved?: unknown; +}; + +type OpenChamberOpencodeResolution = { + configured?: unknown; + resolved?: unknown; + resolvedDir?: unknown; + source?: unknown; + detectedNow?: unknown; + detectedSourceNow?: unknown; + shim?: unknown; + node?: unknown; + bun?: unknown; +}; + const getCurrentDirectory = (): string => { const state = useSessionStore.getState(); const currentSessionId = state.currentSessionId; @@ -85,6 +109,72 @@ export const buildOpenCodeStatusReport = async (): Promise => { const origin = typeof window !== 'undefined' ? window.location.origin : ''; const apiBase = origin ? `${origin.replace(/\/+$/, '')}/api/` : ''; + const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => { + if (!origin) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const resp = await fetch(`${origin.replace(/\/+$/, '')}/health`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + if (!resp.ok) return null; + const json = (await resp.json().catch(() => null)) as unknown; + if (!json || typeof json !== 'object' || Array.isArray(json)) return null; + return json as OpenChamberHealthSnapshot; + } catch { + return null; + } finally { + clearTimeout(timeout); + } + })(); + + const openChamberOpencodeResolutionResult: { + data: OpenChamberOpencodeResolution | null; + status: number | null; + error: string | null; + } = await (async () => { + if (!origin) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 7000); + try { + const resp = await fetch(`${origin.replace(/\/+$/, '')}/api/config/opencode-resolution`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + const contentType = resp.headers.get('content-type') || '(none)'; + if (!resp.ok) { + return { data: null, status: resp.status, error: `http ${resp.status} content-type=${contentType}` }; + } + const raw = await resp.text(); + let json: unknown = null; + try { + json = JSON.parse(raw); + } catch { + const snippet = raw.replace(/\s+/g, ' ').slice(0, 120); + return { + data: null, + status: resp.status, + error: `invalid json content-type=${contentType} body=${snippet || '(empty)'}`, + }; + } + if (!json || typeof json !== 'object' || Array.isArray(json)) { + return { data: null, status: resp.status, error: `invalid json-shape content-type=${contentType}` }; + } + return { data: json as OpenChamberOpencodeResolution, status: resp.status, error: null }; + } catch (error) { + return { + data: null, + status: null, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } + })() || { data: null, status: null, error: null }; + const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => { if (!apiBase) return null; const url = new URL(pathname.replace(/^\/+/, ''), apiBase); @@ -132,6 +222,73 @@ export const buildOpenCodeStatusReport = async (): Promise => { } } + const isLikelyMac = /Mac OS X|Macintosh/.test(platform); + if (isLikelyMac) { + lines.push(''); + lines.push('OpenCode CLI resolution:'); + + const openChamberOpencodeResolution = openChamberOpencodeResolutionResult.data; + const configured = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.configured === 'string' + ? openChamberOpencodeResolution.configured + : null; + const resolved = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.resolved === 'string' + ? openChamberOpencodeResolution.resolved + : (openChamberHealth && typeof openChamberHealth.opencodeBinaryResolved === 'string' ? openChamberHealth.opencodeBinaryResolved : ''); + const resolvedDir = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.resolvedDir === 'string' + ? openChamberOpencodeResolution.resolvedDir + : ''; + const source = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.source === 'string' + ? openChamberOpencodeResolution.source + : (openChamberHealth && typeof openChamberHealth.opencodeBinarySource === 'string' ? openChamberHealth.opencodeBinarySource : ''); + const shim = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.shim === 'string' + ? openChamberOpencodeResolution.shim + : (openChamberHealth && typeof openChamberHealth.opencodeShimInterpreter === 'string' ? openChamberHealth.opencodeShimInterpreter : ''); + const node = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.node === 'string' + ? openChamberOpencodeResolution.node + : (openChamberHealth && typeof openChamberHealth.nodeBinaryResolved === 'string' ? openChamberHealth.nodeBinaryResolved : ''); + const bun = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.bun === 'string' + ? openChamberOpencodeResolution.bun + : (openChamberHealth && typeof openChamberHealth.bunBinaryResolved === 'string' ? openChamberHealth.bunBinaryResolved : ''); + const detectedNow = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.detectedNow === 'string' + ? openChamberOpencodeResolution.detectedNow + : ''; + const detectedSourceNow = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.detectedSourceNow === 'string' + ? openChamberOpencodeResolution.detectedSourceNow + : ''; + + if (configured !== null) { + lines.push(`- configured: ${configured.trim().length === 0 ? '(cleared)' : configured}`); + } + + if (resolved) { + const dir = resolvedDir || (resolved.includes('/') ? resolved.split('/').slice(0, -1).join('/') || '/' : ''); + lines.push(`- opencode: ${resolved}${dir ? ` (dir=${dir})` : ''}`); + } else { + lines.push('- opencode: (n/a)'); + } + + lines.push(`- source: ${source || '(n/a)'}`); + if (detectedNow) { + lines.push(`- detected-now: ${detectedNow}`); + lines.push(`- detected-source: ${detectedSourceNow || '(n/a)'}`); + } + lines.push(`- shim: ${shim || '(n/a)'}`); + lines.push(`- node: ${node || '(n/a)'}`); + lines.push(`- bun: ${bun || '(n/a)'}`); + if (!openChamberOpencodeResolution && openChamberOpencodeResolutionResult.error) { + lines.push(`- resolution-endpoint: ${openChamberOpencodeResolutionResult.error}`); + } + } + lines.push(''); if (probes.length) { lines.push('OpenCode API probes:'); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9aa51025..74ff6cb2 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -229,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index c9591f13..3d55dd06 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.52", + "@opencode-ai/sdk": "^1.1.53", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d5504517..a90cfb68 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1875,6 +1875,7 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( let globalEventWatcherAbortController = null; let resolvedOpencodeBinary = null; +let resolvedOpencodeBinarySource = null; let resolvedNodeBinary = null; let resolvedBunBinary = null; @@ -1927,12 +1928,14 @@ function resolveOpencodeCliPath() { for (const candidate of explicit) { if (isExecutable(candidate)) { + resolvedOpencodeBinarySource = 'env'; return candidate; } } const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { + resolvedOpencodeBinarySource = 'path'; return resolvedFromPath; } @@ -1970,6 +1973,7 @@ function resolveOpencodeCliPath() { const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { if (isExecutable(candidate)) { + resolvedOpencodeBinarySource = 'fallback'; return candidate; } } @@ -1986,7 +1990,10 @@ function resolveOpencodeCliPath() { .map((line) => line.trim()) .filter(Boolean); const found = lines.find((line) => isExecutable(line)); - if (found) return found; + if (found) { + resolvedOpencodeBinarySource = 'where'; + return found; + } } } catch { // ignore @@ -2005,6 +2012,7 @@ function resolveOpencodeCliPath() { if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; if (found && isExecutable(found)) { + resolvedOpencodeBinarySource = 'shell'; return found; } } @@ -2284,6 +2292,7 @@ async function applyOpencodeBinaryFromSettings() { if (normalized === '') { delete process.env.OPENCODE_BINARY; resolvedOpencodeBinary = null; + resolvedOpencodeBinarySource = null; return null; } @@ -2291,6 +2300,7 @@ async function applyOpencodeBinaryFromSettings() { process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); resolvedOpencodeBinary = normalized; + resolvedOpencodeBinarySource = 'settings'; ensureOpencodeShimRuntime(normalized); return normalized; } @@ -2315,6 +2325,7 @@ function ensureOpencodeCliEnv() { const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; if (existing && isExecutable(existing)) { resolvedOpencodeBinary = existing; + resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env'; prependToPath(path.dirname(existing)); ensureOpencodeShimRuntime(existing); return resolvedOpencodeBinary; @@ -2326,6 +2337,7 @@ function ensureOpencodeCliEnv() { prependToPath(path.dirname(resolved)); ensureOpencodeShimRuntime(resolved); resolvedOpencodeBinary = resolved; + resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'unknown'; console.log(`Resolved opencode CLI: ${resolved}`); return resolved; } @@ -3511,6 +3523,7 @@ function setupProxy(app) { req.path.startsWith('/themes/custom') || req.path.startsWith('/push') || req.path.startsWith('/config/agents') || + req.path.startsWith('/config/opencode-resolution') || req.path.startsWith('/config/settings') || req.path.startsWith('/config/skills') || req.path === '/config/reload' || @@ -3544,6 +3557,7 @@ function setupProxy(app) { if ( req.path.startsWith('/themes/custom') || req.path.startsWith('/config/agents') || + req.path.startsWith('/config/opencode-resolution') || req.path.startsWith('/config/settings') || req.path.startsWith('/config/skills') || req.path === '/health' @@ -3720,7 +3734,12 @@ async function main(options = {}) { openCodeApiPrefix: '', openCodeApiPrefixDetected: true, isOpenCodeReady, - lastOpenCodeError + lastOpenCodeError, + opencodeBinaryResolved: resolvedOpencodeBinary || null, + opencodeBinarySource: resolvedOpencodeBinarySource || null, + opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null, + nodeBinaryResolved: resolvedNodeBinary || null, + bunBinaryResolved: resolvedBunBinary || null, }); }); @@ -4305,6 +4324,50 @@ async function main(options = {}) { } }); + app.get('/api/config/opencode-resolution', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null; + + const previousSource = resolvedOpencodeBinarySource; + const detectedNow = resolveOpencodeCliPath(); + const rawDetectedSourceNow = resolvedOpencodeBinarySource; + resolvedOpencodeBinarySource = previousSource; + + // Best-effort: apply configured override (if any) and resolve. + await applyOpencodeBinaryFromSettings(); + ensureOpencodeCliEnv(); + + const resolved = resolvedOpencodeBinary || null; + const source = resolvedOpencodeBinarySource || null; + const detectedSourceNow = + detectedNow && + resolved && + detectedNow === resolved && + rawDetectedSourceNow === 'env' && + source && + source !== 'env' + ? source + : rawDetectedSourceNow; + const shim = resolved ? opencodeShimInterpreter(resolved) : null; + + res.json({ + configured, + resolved, + resolvedDir: resolved ? path.dirname(resolved) : null, + source, + detectedNow, + detectedSourceNow, + shim, + node: resolvedNodeBinary || null, + bun: resolvedBunBinary || null, + }); + } catch (error) { + console.error('Failed to build opencode resolution snapshot:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build snapshot' }); + } + }); + app.get('/api/config/themes', async (_req, res) => { try { const customThemes = await readCustomThemesFromDisk();