diff --git a/src/main/lib/claude/env.ts b/src/main/lib/claude/env.ts index 0ea2ab0cf..44d4679aa 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -24,6 +24,13 @@ const STRIPPED_ENV_KEYS_BASE = [ "OPENAI_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", + // Prevent "Claude Code cannot be launched inside another session" when the + // dev build is spawned from a `claude` CLI terminal — the CLI sets these + // markers on its environment and they propagate into Electron's process.env. + // We unconditionally strip them here and then re-set CLAUDE_CODE_ENTRYPOINT + // to our own value below (order matters: strip → set). + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDECODE", ] // In dev mode, also strip ANTHROPIC_API_KEY so OAuth token is used instead diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index b8c7b00f5..077183c6c 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -2,6 +2,7 @@ import { observable } from "@trpc/server/observable" import { eq } from "drizzle-orm" import { app, BrowserWindow, safeStorage } from "electron" import * as fs from "fs/promises" +import { existsSync } from "node:fs" import * as os from "os" import path from "path" import { z } from "zod" @@ -897,11 +898,34 @@ export const claudeRouter = router({ const db = getDatabase() // 1. Get existing messages from DB - const existing = db + let existing = db .select() .from(subChats) .where(eq(subChats.id, input.subChatId)) .get() + + // Safety net: if the sub-chat row doesn't exist yet (renderer sent + // `send` before `createSubChat` round-tripped), backfill it instead + // of silently no-op'ing the UPDATE below. + if (!existing) { + console.warn( + `[claude] sub-chat ${input.subChatId} missing on send — auto-creating`, + ) + db.insert(subChats) + .values({ + id: input.subChatId, + chatId: input.chatId, + mode: input.mode, + messages: "[]", + }) + .run() + existing = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + } + const existingMessages = JSON.parse(existing?.messages || "[]") const existingSessionId = existing?.sessionId || null @@ -1494,7 +1518,33 @@ export const claudeRouter = router({ } } - const resolvedModel = finalCustomConfig?.model || input.model + const rawResolvedModel = finalCustomConfig?.model || input.model + // Opus 1M: the UI exposes `opus[1m]` as a distinct model, but the + // Claude CLI only understands the `opus` shortcut. To opt into the + // 1M context window we strip the `[1m]` suffix for the model field + // and enable the matching beta via ANTHROPIC_BETAS on the child env. + const isOpus1M = rawResolvedModel === "opus[1m]" + const resolvedModel = isOpus1M ? "opus" : rawResolvedModel + if (isOpus1M) { + const envAsRecord = finalEnv as Record + const existingBetas = envAsRecord.ANTHROPIC_BETAS + const betaSlug = "context-1m-2025-08-07" + const merged = existingBetas + ? Array.from( + new Set([ + ...existingBetas + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + betaSlug, + ]), + ).join(",") + : betaSlug + envAsRecord.ANTHROPIC_BETAS = merged + console.log( + `[claude] Opus 1M context enabled — ANTHROPIC_BETAS=${merged}`, + ) + } // DEBUG: If using Ollama, test if it's actually responding if (isUsingOllama && finalCustomConfig) { @@ -2038,6 +2088,21 @@ ${prompt} // Plan mode: track ExitPlanMode to stop after plan is complete let exitPlanModeToolCallId: string | null = null + // Stream wedge recovery: abort if no first chunk arrives within 90s. + // SDK hangs have been observed with no crash/no error — detect them + // instead of letting the UI sit on a pending stream forever. + let streamWedged = false + const WEDGE_TIMEOUT_MS = 90_000 + const wedgeTimer = setTimeout(() => { + if (!firstMessageReceived) { + streamWedged = true + console.error( + `[claude] Stream wedged — no data in ${WEDGE_TIMEOUT_MS / 1000}s, aborting`, + ) + abortController.abort() + } + }, WEDGE_TIMEOUT_MS) + if (isUsingOllama) { console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`) console.log(`[Ollama] Model: ${finalCustomConfig?.model}`) @@ -2094,6 +2159,7 @@ ${prompt} // Warn if SDK initialization is slow (MCP delay) if (!firstMessageReceived) { firstMessageReceived = true + clearTimeout(wedgeTimer) const timeToFirstMessage = Date.now() - streamIterationStart if (isUsingOllama) { console.log( @@ -2423,6 +2489,8 @@ ${prompt} } } + clearTimeout(wedgeTimer) + // Warn if stream yielded no messages (offline mode issue) const streamDuration = Date.now() - streamIterationStart if (isUsingOllama) { @@ -2469,6 +2537,7 @@ ${prompt} } } catch (streamError) { // This catches errors during streaming (like process exit) + clearTimeout(wedgeTimer) const err = streamError as Error const stderrOutput = stderrLines.join("\n") @@ -2496,7 +2565,10 @@ ${prompt} "No conversation found with session ID", ) - if (isSessionNotFound) { + if (streamWedged) { + errorContext = `Claude stream wedged — no data received in ${WEDGE_TIMEOUT_MS / 1000}s. Try again.` + errorCategory = "STREAM_WEDGE" + } else if (isSessionNotFound) { // Clear the invalid session ID from database so next attempt starts fresh console.log( `[claude] Session not found - clearing invalid sessionId from database`, @@ -2512,8 +2584,15 @@ ${prompt} errorContext = "Claude Code process crashed" errorCategory = "PROCESS_CRASH" } else if (err.message?.includes("ENOENT")) { - errorContext = "Required executable not found in PATH" - errorCategory = "EXECUTABLE_NOT_FOUND" + // If the bundled Claude binary is missing, surface a clear + // recovery path instead of a generic PATH error. + if (!existsSync(claudeBinaryPath)) { + errorContext = `Claude binary not found at ${claudeBinaryPath}. Run \`bun run claude:download\` and restart the app.` + errorCategory = "CLAUDE_BINARY_MISSING" + } else { + errorContext = "Required executable not found in PATH" + errorCategory = "EXECUTABLE_NOT_FOUND" + } } else if ( err.message?.includes("authentication") || err.message?.includes("401") @@ -2564,8 +2643,9 @@ ${prompt} } } - // Send error with stderr output to frontend (only if not aborted by user) - if (!abortController.signal.aborted) { + // Send error with stderr output to frontend (only if not aborted by user). + // Wedge-triggered aborts are NOT user aborts — surface them. + if (!abortController.signal.aborted || streamWedged) { safeEmit({ type: "error", errorText: stderrOutput diff --git a/src/preload/index.ts b/src/preload/index.ts index 6744f93bc..0787765f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,8 +8,23 @@ if (process.env.NODE_ENV === "production") { }) } -// Expose tRPC IPC bridge for type-safe communication -exposeElectronTRPC() +// Expose tRPC IPC bridge for type-safe communication. +// Guard against a race where exposeElectronTRPC throws during preload boot +// (observed as a black screen crash). Surface the error to the renderer via +// a flag the AppErrorBoundary reads on mount, so we can recover instead of +// rendering a blank window. +try { + exposeElectronTRPC() +} catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error("[preload] exposeElectronTRPC failed:", err) + try { + contextBridge.exposeInMainWorld("__ipcBootError", message) + } catch { + // If even the contextBridge call fails, nothing more we can do here — + // the renderer will still show its error boundary on the first React crash. + } +} // Expose webUtils for file path access in drag and drop contextBridge.exposeInMainWorld("webUtils", { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 72fa8d406..b4b29bcf9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,6 +2,7 @@ import { Provider as JotaiProvider, useAtomValue, useSetAtom } from "jotai" import { ThemeProvider, useTheme } from "next-themes" import { useEffect, useMemo } from "react" import { Toaster } from "sonner" +import { AppErrorBoundary } from "./components/ui/error-boundary" import { TooltipProvider } from "./components/ui/tooltip" import { TRPCProvider } from "./contexts/TRPCProvider" import { WindowProvider, getInitialWindowParams } from "./contexts/WindowContext" @@ -202,24 +203,26 @@ export function App() { }, []) return ( - - - - - - -
- -
- -
-
-
-
-
-
+ + + + + + + +
+ +
+ +
+
+
+
+
+
+
) } diff --git a/src/renderer/components/ui/error-boundary.tsx b/src/renderer/components/ui/error-boundary.tsx index e55945877..cb1049ce9 100644 --- a/src/renderer/components/ui/error-boundary.tsx +++ b/src/renderer/components/ui/error-boundary.tsx @@ -1,4 +1,4 @@ -import { Component, type ReactNode } from "react" +import { Component, type ErrorInfo, type ReactNode } from "react" import { AlertCircle } from "lucide-react" import { Button } from "./button" @@ -60,3 +60,95 @@ export class ViewerErrorBoundary extends Component< return this.props.children } } + +interface AppErrorBoundaryState { + hasError: boolean + error: Error | null + ipcBootError: string | null +} + +// Key used to remember a recent auto-reload so a crash loop doesn't infinite-reload. +const AUTO_RELOAD_FLAG = "app:error-boundary:auto-reloaded-at" +const AUTO_RELOAD_WINDOW_MS = 10_000 + +// Root-level error boundary. Catches renderer crashes that would otherwise +// leave the user on a black screen (e.g. from a throwing top-level component +// or a missing IPC bridge during preload boot). Auto-reloads once within a +// short window; subsequent crashes show a manual Reload button instead of +// looping. +export class AppErrorBoundary extends Component< + { children: ReactNode }, + AppErrorBoundaryState +> { + constructor(props: { children: ReactNode }) { + super(props) + const ipcBootError = + typeof window !== "undefined" + ? ((window as unknown as { __ipcBootError?: string }).__ipcBootError ?? + null) + : null + this.state = { + hasError: Boolean(ipcBootError), + error: ipcBootError ? new Error(ipcBootError) : null, + ipcBootError, + } + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[AppErrorBoundary] Root crash:", error, errorInfo) + this.maybeAutoReload() + } + + componentDidMount() { + if (this.state.ipcBootError) { + console.error( + "[AppErrorBoundary] IPC bridge failed to boot:", + this.state.ipcBootError, + ) + this.maybeAutoReload() + } + } + + private maybeAutoReload() { + try { + const last = Number(sessionStorage.getItem(AUTO_RELOAD_FLAG) || 0) + if (!last || Date.now() - last > AUTO_RELOAD_WINDOW_MS) { + sessionStorage.setItem(AUTO_RELOAD_FLAG, String(Date.now())) + console.warn("[AppErrorBoundary] Auto-reloading once to recover") + window.location.reload() + } + } catch { + // sessionStorage unavailable (e.g. in a sandbox) — skip auto-reload + } + } + + handleReload = () => { + window.location.reload() + } + + render() { + if (!this.state.hasError) return this.props.children + + const message = + this.state.ipcBootError || + this.state.error?.message || + "An unexpected error occurred." + + return ( +
+ +

Something went wrong

+

+ {message} +

+ +
+ ) + } +}