From 5fabc88f9e0a0469f94b540607a2aaec97384b3b Mon Sep 17 00:00:00 2001 From: Taylor Beeston <39720479+TaylorBeeston@users.noreply.github.com> Date: Thu, 22 Jan 2026 08:32:04 -0800 Subject: [PATCH] Fix subagent crash and add external OpenCode server support (#188) * :bug: Fix UI crash when subagent is active Remove sessions dependency from hooks to prevent cascading re-renders. Use getState() instead and switch to getGlobalSessionStatus(). * :sparkles: Add support for connecting to external OpenCode server Add OPENCODE_SKIP_START env var to skip starting embedded server. Use OPENCODE_PORT to connect to existing OpenCode instance. Update help text to document the new environment variables. * :memo: Document external OpenCode server support Add OPENCODE_PORT and OPENCODE_SKIP_START to READMEs. Update AGENTS.md with external server integration notes. * :sparkles: Add URL-based routing for shareable session links - Add react-router-dom dependency - Create URL store for bi-directional sync with Zustand - Add WebRouter/DesktopRouter context for runtime-aware routing - Add useURLSync hook to sync URL with session/tab state - Add useNavigation hook with copySessionLink utility - Add share button in Header for copying session links - Update App.tsx to use router wrappers URL structure: /session/:sessionId?tab={chat|git|diff|terminal|files}&directory=/path /settings This enables shareable links and deep-linking to specific sessions. * Revert ":sparkles: Add URL-based routing for shareable session links" This reverts commit b53ee304950a789538fa3d4a236d6361e634ea61. --- AGENTS.md | 1 + README.md | 1 + packages/ui/src/hooks/useEventStream.ts | 68 +++++++++++-------- .../ui/src/hooks/useSessionStatusBootstrap.ts | 4 +- packages/web/README.md | 6 ++ packages/web/bin/cli.js | 11 ++- packages/web/server/index.js | 12 +++- 7 files changed, 68 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01eb241b..6200f8b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ All scripts are in `package.json`. - SSE hookup: `packages/ui/src/hooks/useEventStream.ts` - Web server embeds/starts OpenCode server: `packages/web/server/index.js` (`createOpencodeServer`) - Web runtime filesystem endpoints: search `packages/web/server/index.js` for `/api/fs/` +- External server support: Set `OPENCODE_PORT` and `OPENCODE_SKIP_START=true` to connect to existing OpenCode instance ## Key UI patterns (reference files) - Settings shell: `packages/ui/src/components/views/SettingsView.tsx` diff --git a/README.md b/README.md index 8d698c06..59b1a80a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ openchamber --ui-password secret # Password-protect UI openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login +OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server openchamber stop # Stop server openchamber update # Update to latest version ``` diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 92fb4aa9..68464a40 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -129,7 +129,6 @@ export const useEventStream = () => { dismissQuestion, currentSessionId, applySessionMetadata, - sessions, getWorktreeMetadata, loadMessages, loadSessions, @@ -152,13 +151,15 @@ export const useEventStream = () => { console.warn('Failed to inspect worktree metadata for session directory:', error); } - const sessionRecord = sessions.find((entry) => entry.id === currentSessionId); + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + const sessionRecord = currentSessions.find((entry) => entry.id === currentSessionId); if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { return sessionRecord.directory.trim(); } return undefined; - }, [currentSessionId, getWorktreeMetadata, sessions]); + }, [currentSessionId, getWorktreeMetadata]); const effectiveDirectory = React.useMemo(() => { if (activeSessionDirectory && activeSessionDirectory.length > 0) { @@ -180,9 +181,9 @@ export const useEventStream = () => { return; } - pending.forEach((request) => { + for (const request of pending) { addPermission(request as unknown as PermissionRequest); - }); + } } catch { // ignored } @@ -192,7 +193,9 @@ export const useEventStream = () => { try { const projects = useProjectsStore.getState().projects; const projectDirs = projects.map((project) => project.path); - const sessionDirs = sessions.map((session) => (session as { directory?: string | null }).directory); + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory); const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs]; @@ -201,9 +204,9 @@ export const useEventStream = () => { return; } - pending.forEach((request) => { + for (const request of pending) { addQuestion(request as unknown as QuestionRequest); - }); + } } catch { // ignored } @@ -215,7 +218,7 @@ export const useEventStream = () => { return () => { cancelled = true; }; - }, [addPermission, addQuestion, effectiveDirectory, sessions]); + }, [addPermission, addQuestion, effectiveDirectory]); const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { if (typeof value !== 'string') return null; @@ -236,10 +239,12 @@ export const useEventStream = () => { // ignored } - const record = sessions.find((entry) => entry.id === sessionId); + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + const record = currentSessions.find((entry) => entry.id === sessionId); return normalizeDirectory((record as { directory?: string | null })?.directory ?? null); }, - [getWorktreeMetadata, normalizeDirectory, sessions] + [getWorktreeMetadata, normalizeDirectory] ); const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus); @@ -417,7 +422,9 @@ export const useEventStream = () => { // ignored } - const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null }; + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + const sessionRecord = currentSessions.find((entry) => entry.id === id) as Session & { directory?: string | null }; if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { return sessionRecord.directory.trim(); } @@ -449,7 +456,7 @@ export const useEventStream = () => { } }, 100); }, - [applySessionMetadata, getWorktreeMetadata, sessions] + [applySessionMetadata, getWorktreeMetadata] ); @@ -498,15 +505,17 @@ export const useEventStream = () => { const applyStatusMap = (statusMap: Record) => { const observed = new Set(); - const knownSessionIds = new Set(sessions.map((session) => session.id)); + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + const knownSessionIds = new Set(currentSessions.map((session) => session.id)); - Object.entries(statusMap).forEach(([sessionId, raw]) => { - if (!sessionId || !raw) return; + for (const [sessionId, raw] of Object.entries(statusMap)) { + if (!sessionId || !raw) continue; observed.add(sessionId); const phase: 'idle' | 'busy' = raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle'; updateSessionActivityPhase(sessionId, phase); - }); + } // OpenCode's /session/status may omit idle sessions (returns only busy/retry). // Treat missing entries as idle to avoid sessions getting stuck "working". @@ -530,10 +539,12 @@ export const useEventStream = () => { } const directories = new Set(); - sessions.forEach((session) => { + // Use getState() to avoid sessions dependency which causes cascading updates + const currentSessions = useSessionStore.getState().sessions; + for (const session of currentSessions) { const directory = resolveSessionDirectoryForStatus(session.id); if (directory) directories.add(directory); - }); + } const effective = normalizeDirectory(effectiveDirectory ?? null); if (effective) directories.add(effective); @@ -553,10 +564,10 @@ export const useEventStream = () => { ); const merged: Record = {}; - results.forEach((result) => { - if (result.status !== 'fulfilled' || !result.value) return; + for (const result of results) { + if (result.status !== 'fulfilled' || !result.value) continue; Object.assign(merged, result.value); - }); + } if (Object.keys(merged).length === 0) { const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( @@ -581,7 +592,7 @@ export const useEventStream = () => { sessionStatusRefreshInFlightRef.current = task; return task; - }, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, sessions, updateSessionActivityPhase]); + }, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]); React.useEffect(() => { const nextSessionId = currentSessionId ?? null; @@ -1452,13 +1463,10 @@ export const useEventStream = () => { lastEventTimestampRef.current = Date.now(); publishStatus('connected', null); checkConnection(); - - const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some( - (phase) => phase === 'busy' || phase === 'cooldown' - ); - if (hasBusySessions) { - void refreshSessionActivityStatus(); - } + + // Always refresh session activity status on connect to detect any + // already-running sessions (e.g., started via CLI before UI opened) + void refreshSessionActivityStatus(); if (shouldRefresh) { void bootstrapState('sse_reconnected'); diff --git a/packages/ui/src/hooks/useSessionStatusBootstrap.ts b/packages/ui/src/hooks/useSessionStatusBootstrap.ts index 26666ea6..58a197b7 100644 --- a/packages/ui/src/hooks/useSessionStatusBootstrap.ts +++ b/packages/ui/src/hooks/useSessionStatusBootstrap.ts @@ -15,7 +15,9 @@ export const useSessionStatusBootstrap = () => { const bootstrap = async () => { try { - const statusMap = await opencodeClient.getSessionStatus(); + // Use global status to detect busy sessions across all directories, + // including sessions started externally (e.g., via CLI) before UI opened + const statusMap = await opencodeClient.getGlobalSessionStatus(); if (cancelled || !statusMap) return; const phases = new Map(); diff --git a/packages/web/README.md b/packages/web/README.md index 8cf524f1..294e7636 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -31,10 +31,16 @@ openchamber # Start on port 3000 openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI +OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server openchamber stop # Stop server openchamber update # Update to latest version ``` +### Environment Variables + +- `OPENCODE_PORT` - Port of external OpenCode server to connect to (instead of starting embedded server) +- `OPENCODE_SKIP_START` - Skip starting embedded OpenCode server (use with `OPENCODE_PORT` to connect to external instance) + ## Prerequisites - [OpenCode CLI](https://opencode.ai) installed (`opencode`) diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 1bbb81d6..aeb7572e 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -153,7 +153,7 @@ function parseArgs() { function showHelp() { console.log(` -OpenChamber - Web interface for the OpenCode AI coding agent + OpenChamber - Web interface for the OpenCode AI coding agent USAGE: openchamber [COMMAND] [OPTIONS] @@ -176,7 +176,9 @@ OPTIONS: -v, --version Show version ENVIRONMENT: - OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag + OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag + OPENCODE_PORT Port of external OpenCode server to connect to + OPENCODE_SKIP_START Skip starting OpenCode, use external server EXAMPLES: openchamber # Start on default port 3000 (or a free port) @@ -506,6 +508,7 @@ const commands = { OPENCODE_BINARY: opencodeBinary, ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', + ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), } }); @@ -574,6 +577,9 @@ const commands = { if (typeof effectiveUiPassword === 'string') { process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } + if (process.env.OPENCODE_SKIP_START) { + process.env.OPENCHAMBER_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START; + } if (showAutoGeneratedPassword) { console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); console.log('⚠️ Save this password - it won\'t be shown again!\n'); @@ -590,6 +596,7 @@ const commands = { OPENCODE_BINARY: opencodeBinary, ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', + ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), }, }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 381cf248..4c6f2736 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1340,6 +1340,9 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; })(); +const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || + process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; + const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' ); @@ -5664,12 +5667,17 @@ async function main(options = {}) { try { - // Check if we can reuse an existing OpenCode process from a previous HMR cycle syncFromHmrState(); if (await isOpenCodeProcessHealthy()) { console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); + } else if (ENV_SKIP_OPENCODE_START && ENV_CONFIGURED_OPENCODE_PORT) { + console.log(`Using external OpenCode server on port ${ENV_CONFIGURED_OPENCODE_PORT} (skip-start mode)`); + setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); + isOpenCodeReady = true; + lastOpenCodeError = null; + openCodeNotReadySince = 0; + syncToHmrState(); } else { - // No healthy process, start fresh if (ENV_CONFIGURED_OPENCODE_PORT) { console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);