Просмотр исходного кода

Changed communication between UI <-> IDE to HTTP + SSE
Remote-SSH support initial implementation

paviko 2 недель назад
Родитель
Сommit
48a0d6c417

+ 18 - 69
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/backendprocess/BackendLauncher.kt

@@ -32,7 +32,6 @@ object BackendLauncher {
 
     /**
      * Launches the backend process.
-     * IMPORTANT: This method performs heavy I/O (binary extraction) and must NOT be called from EDT.
      */
     fun launchBackend(project: Project): BackendProcess {
         require(!ApplicationManager.getApplication().isDispatchThread) {
@@ -74,20 +73,11 @@ object BackendLauncher {
         // Start waiting for terminal availability asynchronously
         waitForTerminalAvailabilityAsync(project) { success, isVisible ->
             if (success) {
-                val app = ApplicationManager.getApplication()
-                val run = Runnable {
-                    try {
-                        val result = doLaunchBackend(project, args, baseDir, customCommand, outputBuffer, isVisible)
-                        callback(result, null)
-                    } catch (e: Exception) {
-                        callback(null, e)
-                    }
-                }
-
-                if (app.isDispatchThread) {
-                    run.run()
-                } else {
-                    app.invokeLater(run)
+                try {
+                    val result = doLaunchBackend(project, args, baseDir, customCommand, outputBuffer, isVisible)
+                    callback(result, null)
+                } catch (e: Exception) {
+                    callback(null, e)
                 }
             } else {
                 callback(null, RuntimeException("Terminal tool window is not available. Please ensure the Terminal plugin is installed and enabled."))
@@ -103,52 +93,24 @@ object BackendLauncher {
         val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, project)
         val maxAttempts = 100 // 10 seconds with 100ms intervals
         var attempts = 0
-        var initShowRequested = false
 
-        // Capture visibility state before we potentially show Terminal to bootstrap it.
-        // This is used to restore the user's original UI state.
-        val initiallyVisible = ToolWindowManager.getInstance(project).getToolWindow("Terminal")?.isVisible ?: false
+        val isVisible = ToolWindowManager.getInstance(project).getToolWindow("Terminal")?.isVisible ?: false
 
         fun checkAvailability() {
-            if (project.isDisposed) {
-                callback(false, false)
-                return
-            }
-
             try {
-                val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Terminal")
                 val terminalManager = TerminalToolWindowManager.getInstance(project)
                 val terminalWindow: ToolWindow? = terminalManager.toolWindow
-
-                // The terminal tool window is lazily initialized on some IDE versions.
-                // If it wasn't opened before, TerminalToolWindowManager.toolWindow can stay null forever.
-                // Force initialization by showing it once; it will be hidden back later if it wasn't visible.
-                if (!initShowRequested && toolWindow != null && terminalWindow == null) {
-                    initShowRequested = true
-                    toolWindow.show(null)
-                }
-
                 if (terminalWindow != null && terminalWindow.isAvailable) {
                     logger.info("Terminal tool window is available after ${attempts * 100}ms")
-
-                    if (initShowRequested && !initiallyVisible) {
-                        try {
-                            ApplicationManager.getApplication().invokeLater {
-                                try {
-                                    toolWindow?.hide(null)
-                                } catch (_: Throwable) {}
-                            }
-                        } catch (_: Throwable) {}
-                    }
-
-                    callback(true, initiallyVisible)
+                    callback(true, isVisible)
                     return
                 }
 
+                ToolWindowManager.getInstance(project).getToolWindow("Terminal")?.show()
                 attempts++
                 if (attempts >= maxAttempts) {
                     logger.error("Terminal tool window did not become available within ${maxAttempts * 100}ms")
-                    callback(false, initiallyVisible)
+                    callback(true, isVisible)
                     return
                 }
 
@@ -158,7 +120,7 @@ object BackendLauncher {
                 callback(false, false)
             }
         }
-        
+
         logger.info("Waiting for terminal tool window to become available...")
         alarm.addRequest({ checkAvailability() }, 0)
     }
@@ -227,10 +189,8 @@ object BackendLauncher {
 
         val widget: Any = if (existing != null) {
             logger.info("Reusing existing terminal '$terminalName'")
-
             // Workaround for JetBrains behavior: existing terminal tab may not run commands unless focused
             focusTerminal(project, isVisible, terminalToolWindow, existing, minimized, terminalName)
-
             existing
         } else {
             terminalManager.createShellWidget(workingDir, terminalName, false, !minimized)
@@ -260,7 +220,7 @@ object BackendLauncher {
                 }
             }
         }
-        
+
         return Pair(terminalWidget, TerminalSelection(previousSelected, currentContent))
     }
 
@@ -273,35 +233,24 @@ object BackendLauncher {
         terminalName: String
     ) {
         try {
-            val app = ApplicationManager.getApplication()
-
             // Show the terminal tool window if it is not visible
-            // Use invokeLater instead of invokeAndWait to avoid potential deadlocks
             if (!isVisible) {
+                val app = ApplicationManager.getApplication()
                 if (app.isDispatchThread) {
                     terminalToolWindow?.show(null)
                 } else {
-                    app.invokeLater { terminalToolWindow?.show(null) }
+                    app.invokeAndWait { terminalToolWindow?.show(null) }
                 }
             }
 
             // Focus/select the existing tab to ensure it receives input/execution
-            // Use invokeLater instead of invokeAndWait to avoid potential deadlocks
             try {
                 if (existing != null) {
                     val content = getContentForWidget(project, existing)
                     if (content != null) {
-                        val action = Runnable {
-                            try {
-                                terminalToolWindow?.contentManager?.setSelectedContent(content, true)
-                                terminalToolWindow?.activate(null, true)
-                            } catch (_: Throwable) {}
-                        }
-
-                        if (app.isDispatchThread) {
-                            action.run()
-                        } else {
-                            app.invokeLater(action)
+                        ApplicationManager.getApplication().invokeAndWait {
+                            terminalToolWindow?.contentManager?.setSelectedContent(content, true)
+                            terminalToolWindow?.activate(null, true)
                         }
                     }
                 }
@@ -329,7 +278,7 @@ object BackendLauncher {
         workingDir: String,
         outputBuffer: PipedOutputStream,
         isVisible: Boolean,
-        minimized: Boolean = false
+        minimized: Boolean = false 
     ): BackendProcess {
         // Create terminal widget using unified method - this will handle terminal initialization
         val (shellWidget, selection) = createShellWidget(project, workingDir, "Opencode Backend", isVisible, minimized)
@@ -342,7 +291,7 @@ object BackendLauncher {
         
         // Create a terminal-only backend process
         val backendProcess = RunningTerminalBackendProcess(shellWidget, adjustedArgs.joinToString(" "), outputBuffer)
-        
+
         // Execute the command in the terminal - this inherits full environment
         shellWidget.executeCommand(command)
 

+ 0 - 6
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/backendprocess/TerminalBackendProcess.kt

@@ -59,12 +59,6 @@ internal class TerminalBackendProcess(
         get() = inputStreamBuffer
 
     override fun waitFor(): Int {
-        // Avoid blocking the IDE UI thread.
-        if (ApplicationManager.getApplication().isDispatchThread) {
-            logger.warn("waitFor called on UI thread; returning immediately")
-            return -1
-        }
-
         try {
             readyLatch.await()
         } catch (e: InterruptedException) {

+ 37 - 11
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt

@@ -12,7 +12,6 @@ import com.intellij.openapi.wm.ToolWindow
 import com.intellij.openapi.wm.ToolWindowFactory
 import com.intellij.ui.jcef.JBCefApp
 import com.intellij.ui.jcef.JBCefBrowser
-import com.intellij.ui.jcef.JBCefClient
 import com.intellij.util.concurrency.AppExecutorUtil
 import com.intellij.util.ui.JBUI
 import paviko.opencode.backendprocess.BackendLauncher
@@ -101,10 +100,7 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
         val logBuffer = StringBuilder()
         val logFlushScheduled = AtomicBoolean(false)
 
-        fun queueLog(line: String) {
-            synchronized(logLock) {
-                logBuffer.append(line).append('\n')
-            }
+        fun scheduleLogFlush() {
             if (!logFlushScheduled.compareAndSet(false, true)) return
             SwingUtilities.invokeLater {
                 val chunk = synchronized(logLock) {
@@ -118,11 +114,23 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                     val overflow = doc.length - maxLogChars
                     if (overflow > 0) doc.remove(0, overflow)
                 } catch (_: Throwable) {}
+
                 logFlushScheduled.set(false)
+
+                // If new logs arrived while we were flushing, schedule again.
+                val hasMore = synchronized(logLock) { logBuffer.isNotEmpty() }
+                if (hasMore) scheduleLogFlush()
+            }
+        }
+
+        fun queueLog(line: String) {
+            synchronized(logLock) {
+                logBuffer.append(line).append('\n')
             }
+            scheduleLogFlush()
         }
 
-        val timeoutMs = 60_000L
+        val timeoutMs = 300_000L
         val timeoutFuture = AppExecutorUtil.getAppScheduledExecutorService().schedule({
             if (connected.get()) return@schedule
             logger.warn("Backend connection timeout after ${timeoutMs}ms")
@@ -137,7 +145,6 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
             timeoutFuture.cancel(false)
             try { procRef.get()?.destroy() } catch (_: Throwable) {}
             try { procRef.get()?.inputStream?.close() } catch (_: Throwable) {}
-            IdeBridge.remove(project)
         }
 
         AppExecutorUtil.getAppExecutorService().execute {
@@ -183,10 +190,10 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                                     SwingUtilities.invokeLater {
                                         try {
                                             val client = JBCefApp.getInstance().createClient()
-                                            try { client.setProperty(JBCefClient.Properties.JS_QUERY_POOL_SIZE, 1) } catch (_: Throwable) {}
+                                            
+                                            // Create browser WITHOUT URL first
                                             val browser = JBCefBrowser.createBuilder()
                                                 .setClient(client)
-                                                .setUrl(withCacheBuster(appUrl, pluginVersion()))
                                                 .build()
 
                                             try {
@@ -201,9 +208,28 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                                             mainPanel.revalidate()
                                             mainPanel.repaint()
 
-                                            IdeBridge.install(browser, project)
+                                            // Create bridge session and build URL with bridge params
+                                            val session = IdeBridge.createSession(project)
+                                            val baseUrl = withCacheBuster(appUrl, pluginVersion())
+                                            val urlWithBridge = buildString {
+                                                append(baseUrl)
+                                                append(if ('?' in baseUrl) '&' else '?')
+                                                append("ideBridge=")
+                                                append(URLEncoder.encode(session.baseUrl, StandardCharsets.UTF_8))
+                                                append("&ideBridgeToken=")
+                                                append(URLEncoder.encode(session.token, StandardCharsets.UTF_8))
+                                            }
+                                            
+                                            // Load the URL with bridge params
+                                            browser.loadURL(urlWithBridge)
+                                            
+                                            // Register cleanup for the session
+                                            Disposer.register(toolWindow.disposable) {
+                                                IdeBridge.removeSession(session.sessionId)
+                                            }
+                                            
                                             try {
-                                                val filesUpdater = IdeOpenFilesUpdater(project, browser)
+                                                val filesUpdater = IdeOpenFilesUpdater(project, browser, session.sessionId)
                                                 filesUpdater.install()
                                                 Disposer.register(browser, filesUpdater)
                                             } catch (e: Exception) {

+ 10 - 9
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/DragAndDropInstaller.kt

@@ -19,16 +19,17 @@ object DragAndDropInstaller {
                     val t = dtde.transferable
                     val flavor = DataFlavor.javaFileListFlavor
                     if (t.isDataFlavorSupported(flavor)) {
-                        AppExecutorUtil.getAppExecutorService().execute {
-                            val files = try {
-                                @Suppress("UNCHECKED_CAST")
-                                t.getTransferData(flavor) as List<java.io.File>
-                            } catch (e: Exception) {
-                                logger.warn("Failed to read dropped files", e)
-                                emptyList()
-                            }
-                            if (files.isEmpty()) return@execute
+                        val files = try {
+                            @Suppress("UNCHECKED_CAST")
+                            t.getTransferData(flavor) as List<java.io.File>
+                        } catch (e: Exception) {
+                            logger.warn("Failed to read dropped files", e)
+                            emptyList()
+                        }
 
+                        if (files.isEmpty()) return
+
+                        AppExecutorUtil.getAppExecutorService().execute {
                             val filePaths = files.asSequence().filter { it.isFile }.map { it.absolutePath }.toList()
                             if (filePaths.isNotEmpty()) {
                                 IdeBridge.send(project, "insertPaths", mapOf("paths" to filePaths))

+ 304 - 149
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -1,6 +1,7 @@
 package paviko.opencode.ui
 
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.google.gson.Gson
+import com.google.gson.JsonObject
 import com.intellij.ide.BrowserUtil
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.openapi.diagnostic.Logger
@@ -10,148 +11,327 @@ import com.intellij.openapi.fileEditor.FileEditorManager
 import com.intellij.openapi.fileEditor.OpenFileDescriptor
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.vfs.LocalFileSystem
-import com.intellij.ui.jcef.JBCefBrowser
-import com.intellij.ui.jcef.JBCefJSQuery
-import com.intellij.util.concurrency.AppExecutorUtil
-import org.cef.browser.CefBrowser
-import org.cef.browser.CefFrame
-import org.cef.handler.CefLoadHandlerAdapter
-import javax.swing.SwingUtilities
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+import java.io.OutputStreamWriter
+import java.net.InetSocketAddress
+import java.net.URLDecoder
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+
+data class Session(
+    val id: String,
+    val token: String,
+    val project: Project,
+    val sseClients: MutableSet<HttpExchange> = Collections.synchronizedSet(mutableSetOf())
+)
+
+data class SessionInfo(val baseUrl: String, val token: String, val sessionId: String)
 
 object IdeBridge {
-    private val logger = Logger.getInstance(IdeBridge::class.java)
-    private val mapper = jacksonObjectMapper()
+    private val LOG = Logger.getInstance(IdeBridge::class.java)
+    private val gson = Gson()
     
-    private class ProjectState(
-        val browser: JBCefBrowser,
-        val query: JBCefJSQuery?,
-        val outbox: java.util.concurrent.ConcurrentLinkedQueue<Map<String, Any?>> = java.util.concurrent.ConcurrentLinkedQueue()
-    ) {
-        @Volatile var ready = false
+    private var server: HttpServer? = null
+    private var port: Int = 0
+    private val sessions = ConcurrentHashMap<String, Session>()
+    private val projectToSession = ConcurrentHashMap<Project, String>()
+    @Volatile private var executor = Executors.newCachedThreadPool()
+    private var keepaliveTimer: java.util.Timer? = null
+
+    @Synchronized
+    fun start() {
+        if (server != null) return
+
+        // If stop() was called previously, executor may be shutdown.
+        if (executor.isShutdown) executor = Executors.newCachedThreadPool()
+        
+        server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
+            executor = [email protected]
+            createContext("/idebridge") { exchange -> handleRequest(exchange) }
+            start()
+        }
+        port = server!!.address.port
+        LOG.info("IdeBridge server started on port $port")
     }
 
-    private val states = java.util.concurrent.ConcurrentHashMap<Project, ProjectState>()
+    @Synchronized
+    fun stop() {
+        keepaliveTimer?.cancel()
+        keepaliveTimer = null
+        server?.stop(0)
+        server = null
+        sessions.clear()
+        projectToSession.clear()
+        try { executor.shutdownNow() } catch (_: Throwable) {}
+    }
 
-    fun install(browser: JBCefBrowser, project: Project) {
-        // Create JBCefJSQuery immediately (requires JS_QUERY_POOL_SIZE set on client)
-        val q = try { JBCefJSQuery.create(browser) } catch (t: Throwable) { 
-            logger.warn("Failed to create JBCefJSQuery", t)
-            null 
+    fun createSession(project: Project): SessionInfo {
+        start() // ensure server is running
+        
+        // Remove any existing session for this project
+        projectToSession[project]?.let { oldId ->
+            removeSession(oldId)
         }
         
-        val state = ProjectState(browser, q)
-        states[project] = state
+        val sessionId = UUID.randomUUID().toString()
+        val token = UUID.randomUUID().toString()
+        sessions[sessionId] = Session(sessionId, token, project)
+        projectToSession[project] = sessionId
         
-        if (q != null) {
-            try {
-                q.addHandler { payload ->
-                    val json = payload ?: "{}"
-                    AppExecutorUtil.getAppExecutorService().execute {
-                        try {
-                            handleInbound(json, project)
-                        } catch (t: Throwable) {
-                            logger.warn("ideBridge inbound error", t)
-                        }
+        // Start keepalive timer if not running
+        if (keepaliveTimer == null) {
+            keepaliveTimer = java.util.Timer("IdeBridge-Keepalive", true).apply {
+                scheduleAtFixedRate(object : java.util.TimerTask() {
+                    override fun run() {
+                        sendKeepaliveToAll()
                     }
-                    null
-                }
-            } catch (_: Throwable) {}
+                }, 15000, 15000) // Every 15 seconds
+            }
         }
         
-        val sendInvoke = try { q?.inject("String(json)") } catch (_: Throwable) { null } ?: "void 0"
-        
-        // Use onLoadEnd to inject JS after page loads - handles race condition on Windows IDEA 2024.3
-        // Re-injects on every page load since JS context resets on navigation
-        val loadHandler = object : CefLoadHandlerAdapter() {
-            override fun onLoadEnd(cefBrowser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) {
-                // Only inject in main frame
-                if (frame?.isMain == true) {
-                    SwingUtilities.invokeLater {
-                        injectBridgeJs(browser, project, sendInvoke)
+        val baseUrl = "http://127.0.0.1:$port/idebridge/$sessionId"
+        return SessionInfo(baseUrl, token, sessionId)
+    }
+
+    fun removeSession(sessionId: String) {
+        sessions.remove(sessionId)?.let { session ->
+            projectToSession.remove(session.project)
+            synchronized(session.sseClients) {
+                session.sseClients.forEach { 
+                    try { it.close() } catch (_: Throwable) {}
+                }
+            }
+        }
+    }
+
+    fun send(sessionId: String, type: String, payload: Map<String, Any?> = emptyMap()) {
+        val session = sessions[sessionId] ?: return
+        val msg = JsonObject().apply {
+            addProperty("type", type)
+            add("payload", gson.toJsonTree(payload))
+            addProperty("timestamp", System.currentTimeMillis())
+        }
+        broadcastSSE(session, gson.toJson(msg))
+    }
+    
+    /**
+     * Send a message to UI using project reference (looks up session automatically).
+     * Used by PathInserter, DragAndDropInstaller, and other utilities.
+     */
+    fun send(project: Project, type: String, payload: Map<String, Any?> = emptyMap()) {
+        val sessionId = projectToSession[project]
+        if (sessionId == null) {
+            LOG.warn("No session found for project: ${project.name}")
+            return
+        }
+        send(sessionId, type, payload)
+    }
+    
+    private fun sendKeepaliveToAll() {
+        sessions.values.forEach { session ->
+            synchronized(session.sseClients) {
+                val toRemove = mutableListOf<HttpExchange>()
+                session.sseClients.forEach { client ->
+                    try {
+                        val writer = OutputStreamWriter(client.responseBody)
+                        writer.write(": ping\n\n")
+                        writer.flush()
+                    } catch (e: Exception) {
+                        toRemove.add(client)
                     }
                 }
+                toRemove.forEach {
+                    session.sseClients.remove(it)
+                    try { it.close() } catch (_: Throwable) {}
+                }
+            }
+        }
+    }
+
+    private fun handleRequest(exchange: HttpExchange) {
+        // Add CORS headers
+        exchange.responseHeaders.apply {
+            add("Access-Control-Allow-Origin", "*")
+            add("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+            add("Access-Control-Allow-Headers", "Content-Type")
+        }
+
+        if (exchange.requestMethod == "OPTIONS") {
+            exchange.sendResponseHeaders(204, -1)
+            exchange.close()
+            return
+        }
+
+        // Parse path: /idebridge/{sessionId}/{action}
+        val pathParts = exchange.requestURI.path.split("/").filter { it.isNotEmpty() }
+        if (pathParts.size < 3 || pathParts[0] != "idebridge") {
+            exchange.sendResponseHeaders(404, -1)
+            exchange.close()
+            return
+        }
+
+        val sessionId = pathParts[1]
+        val action = pathParts[2]
+        val session = sessions[sessionId]
+
+        // Parse token from query
+        val queryParams = parseQuery(exchange.requestURI.rawQuery ?: "")
+        val token = queryParams["token"]
+
+        if (session == null || session.token != token) {
+            exchange.sendResponseHeaders(401, -1)
+            exchange.close()
+            return
+        }
+
+        when (action) {
+            "events" -> handleSSE(exchange, session)
+            "send" -> handleSend(exchange, session)
+            else -> {
+                exchange.sendResponseHeaders(404, -1)
+                exchange.close()
             }
         }
+    }
+
+    private fun handleSSE(exchange: HttpExchange, session: Session) {
+        exchange.responseHeaders.apply {
+            add("Content-Type", "text/event-stream")
+            add("Cache-Control", "no-cache, no-transform")
+            add("Connection", "keep-alive")
+            add("X-Accel-Buffering", "no") // Disable nginx buffering
+        }
+        exchange.sendResponseHeaders(200, 0)
         
-        browser.jbCefClient.addLoadHandler(loadHandler, browser.cefBrowser)
+        synchronized(session.sseClients) {
+            session.sseClients.add(exchange)
+        }
+        
+        // Send initial connection event
+        try {
+            val writer = OutputStreamWriter(exchange.responseBody)
+            writer.write("event: connected\ndata: {}\n\n")
+            writer.flush()
+        } catch (e: Exception) {
+            synchronized(session.sseClients) {
+                session.sseClients.remove(exchange)
+            }
+            try { exchange.close() } catch (_: Throwable) {}
+        }
+        
+        // Keep connection open - will be cleaned up when client disconnects or session removed
     }
-    
-    private fun injectBridgeJs(browser: JBCefBrowser, project: Project, sendInvoke: String) {
+
+    private fun handleSend(exchange: HttpExchange, session: Session) {
+        if (exchange.requestMethod != "POST") {
+            exchange.sendResponseHeaders(405, -1)
+            exchange.close()
+            return
+        }
+
         try {
-            val js = (
-                "(function(){" +
-                "if(!window.ideBridge){" +
-                "  var q=[];" +
-                "  window.ideBridge={ready:false,send:function(m){try{var s=(typeof m==='string')?m:JSON.stringify(m); if(typeof window.__ideBridgeSend==='function'){window.__ideBridgeSend(s);} else {q.push(s);}}catch(e){}},request:function(m){return new Promise(function(res,rej){try{var id=String(Date.now())+Math.random().toString(36).slice(2); m.id=id; var r=function(msg){try{if(msg && msg.replyTo===id){window.removeEventListener('message',rWrap); res(msg);} }catch(e){} }; var rWrap=function(ev){try{ r(ev.data||ev); }catch(e){} }; window.addEventListener('message', rWrap); window.ideBridge.send(m);}catch(e){rej(e)}});},onMessage:function(h){window.__ideBridgeOnMessage=h}};" +
-                "  window.__ideBridgeSend=function(json){$sendInvoke};" +
-                "  window.__ideBridgeDeliver=function(s){try{var m=(typeof s==='string')?JSON.parse(s):s; if(typeof window.__ideBridgeOnMessage==='function'){window.__ideBridgeOnMessage(m);} else {window.postMessage(m,'*');}}catch(e){}};" +
-                "  window.ideBridge._flush=function(){try{window.ideBridge.ready=true; var a=q.splice(0,q.length); for(var i=0;i<a.length;i++){try{window.__ideBridgeSend(a[i])}catch(e){}}}catch(e){}};" +
-                "}" +
-                "})();"
-            )
-            browser.cefBrowser.executeJavaScript(js, browser.cefBrowser.url, 0)
-            val state = states[project]
-            if (state != null) {
-                state.ready = true
-                flushOutbox(project)
+            val body = exchange.requestBody.bufferedReader().readText()
+            val msg = gson.fromJson(body, JsonObject::class.java)
+            
+            val type = msg.get("type")?.asString
+            val id = msg.get("id")?.asString
+            val payload = msg.getAsJsonObject("payload")
+
+            when (type) {
+                "openFile" -> {
+                    val rawPath = payload?.get("path")?.asString
+                    if (rawPath != null) {
+                        val lineFromPayload1Based = payload.get("line")?.asInt ?: -1
+                        val rangeRegex = Regex(":(\\d+)(?:-(\\d+))?$")
+                        val match = rangeRegex.find(rawPath)
+                        val startFromPath1Based = try {
+                            match?.groupValues?.getOrNull(1)?.toInt()
+                        } catch (_: Throwable) { null }
+                        val endFromPath1Based = try {
+                            match?.groupValues?.getOrNull(2)?.toInt()
+                        } catch (_: Throwable) { null }
+                        val cleanedPath = rawPath.replace(rangeRegex, "")
+
+                        val startLine1Based = if (lineFromPayload1Based > 0) lineFromPayload1Based else startFromPath1Based ?: -1
+                        val endLine1Based = endFromPath1Based ?: -1
+
+                        val startLine0Based = if (startLine1Based > 0) startLine1Based - 1 else -1
+                        val endLine0Based = if (endLine1Based > 0) endLine1Based - 1 else -1
+
+                        openFile(session.project, cleanedPath, startLine0Based, endLine0Based)
+                        replyOk(session, id)
+                    } else {
+                        replyError(session, id, "Missing path")
+                    }
+                }
+                "openUrl" -> {
+                    val url = payload?.get("url")?.asString
+                    if (url != null) {
+                        BrowserUtil.browse(url)
+                        replyOk(session, id)
+                    } else {
+                        replyError(session, id, "Missing url")
+                    }
+                }
+                "reloadPath" -> {
+                    val path = payload?.get("path")?.asString
+                    if (path != null) {
+                        reloadPath(path)
+                        replyOk(session, id)
+                    } else {
+                        replyError(session, id, "Missing path")
+                    }
+                }
+                else -> replyError(session, id, "Unknown type: $type")
             }
 
-            // JetBrains JCEF does not reliably show native title tooltips; enable UI polyfill.
-            send(project, "setTooltipPolyfill", mapOf("enabled" to true))
-        } catch (t: Throwable) {
-            logger.warn("Failed to inject ideBridge", t)
+            exchange.sendResponseHeaders(204, -1)
+        } catch (e: Exception) {
+            LOG.warn("Error handling send", e)
+            exchange.sendResponseHeaders(400, -1)
         }
+        exchange.close()
     }
 
-    fun remove(project: Project) {
-        states.remove(project)
+    private fun replyOk(session: Session, id: String?) {
+        if (id == null) return
+        val msg = JsonObject().apply {
+            addProperty("replyTo", id)
+            addProperty("ok", true)
+            addProperty("timestamp", System.currentTimeMillis())
+        }
+        broadcastSSE(session, gson.toJson(msg))
     }
 
-    private fun handleInbound(json: String, project: Project) {
-        val obj = try { mapper.readTree(json) } catch (_: Throwable) { null } ?: return
-        val id = obj.get("id")?.asText()
-        val type = obj.get("type")?.asText() ?: return
-        when (type) {
-            "openFile" -> {
-                val payload = obj.get("payload")
-                val rawPath = payload?.get("path")?.asText() ?: return replyError(project, id, "missing path")
-                val lineFromPayload1Based = payload.get("line")?.asInt() ?: -1
-                val rangeRegex = Regex(":(\\d+)(?:-(\\d+))?$")
-                val match = rangeRegex.find(rawPath)
-                val startFromPath1Based = try {
-                    match?.groupValues?.getOrNull(1)?.toInt()
-                } catch (_: Throwable) { null }
-                val endFromPath1Based = try {
-                    match?.groupValues?.getOrNull(2)?.toInt()
-                } catch (_: Throwable) { null }
-                val cleanedPath = rawPath.replace(rangeRegex, "")
-
-                val startLine1Based = if (lineFromPayload1Based > 0) lineFromPayload1Based else startFromPath1Based ?: -1
-                val endLine1Based = endFromPath1Based ?: -1
-
-                val startLine0Based = if (startLine1Based > 0) startLine1Based - 1 else -1
-                val endLine0Based = if (endLine1Based > 0) endLine1Based - 1 else -1
-
-                openFile(project, cleanedPath, startLine0Based, endLine0Based)
-                replyOk(project, id)
-            }
-            "openUrl" -> {
-                val payload = obj.get("payload")
-                val url = payload?.get("url")?.asText() ?: return replyError(project, id, "missing url")
+    private fun replyError(session: Session, id: String?, error: String) {
+        if (id == null) return
+        val msg = JsonObject().apply {
+            addProperty("replyTo", id)
+            addProperty("ok", false)
+            addProperty("error", error)
+            addProperty("timestamp", System.currentTimeMillis())
+        }
+        broadcastSSE(session, gson.toJson(msg))
+    }
+
+    private fun broadcastSSE(session: Session, json: String) {
+        synchronized(session.sseClients) {
+            val toRemove = mutableListOf<HttpExchange>()
+            session.sseClients.forEach { client ->
                 try {
-                    BrowserUtil.browse(url)
-                    replyOk(project, id)
-                } catch (t: Throwable) {
-                    replyError(project, id, t.message ?: "Failed to open url")
+                    val writer = OutputStreamWriter(client.responseBody)
+                    writer.write("event: message\ndata: $json\n\n")
+                    writer.flush()
+                } catch (e: Exception) {
+                    toRemove.add(client)
                 }
             }
-            "reloadPath" -> {
-                val payload = obj.get("payload")
-                val path = payload?.get("path")?.asText() ?: return replyError(project, id, "missing path")
-                reloadFile(path)
-                replyOk(project, id)
+            toRemove.forEach { 
+                session.sseClients.remove(it)
+                try { it.close() } catch (_: Throwable) {}
             }
-            else -> replyOk(project, id)
         }
     }
 
@@ -196,15 +376,11 @@ object IdeBridge {
                 }
             }
         } catch (t: Throwable) {
-            logger.warn("openFile failed", t)
+            LOG.warn("openFile failed", t)
         }
     }
 
-    @Suppress("UNUSED_PARAMETER")
-    private fun replyOk(project: Project, replyTo: String?) { sendRaw(project, mapOf("replyTo" to replyTo, "ok" to true)) }
-    private fun replyError(project: Project, replyTo: String?, error: String) { sendRaw(project, mapOf("replyTo" to replyTo, "ok" to false, "error" to error)) }
-
-    private fun reloadFile(path: String) {
+    private fun reloadPath(path: String) {
         try {
             val lfs = LocalFileSystem.getInstance()
             val vf = lfs.findFileByPath(path) ?: lfs.refreshAndFindFileByPath(path)
@@ -218,39 +394,18 @@ object IdeBridge {
                 parentVf?.refresh(true, true)
             }
         } catch (t: Throwable) {
-            logger.warn("reloadFile failed", t)
-        }
-    }
-
-    fun send(project: Project, type: String, payload: Map<String, Any?> = emptyMap()) {
-        val message = mutableMapOf<String, Any?>("type" to type, "timestamp" to System.currentTimeMillis())
-        if (payload.isNotEmpty()) message["payload"] = payload
-        sendRaw(project, message)
-    }
-
-    private fun sendRaw(project: Project, message: Map<String, Any?>) {
-        val state = states[project] ?: return
-        AppExecutorUtil.getAppExecutorService().execute {
-            val b = state.browser
-
-            val json = try { mapper.writeValueAsString(message) } catch (_: Throwable) { return@execute }
-            val script = "(function(){ try { if(window.__ideBridgeDeliver){ window.__ideBridgeDeliver(" + mapper.writeValueAsString(json) + "); } else { window.postMessage(" + json + ", '*'); } } catch(e){} })();"
-            try {
-                b.cefBrowser.executeJavaScript(script, b.cefBrowser.url, 0)
-                state.ready = true
-            } catch (_: Throwable) {
-                state.outbox.add(message)
-            }
+            LOG.warn("reloadPath failed", t)
         }
     }
 
-    private fun flushOutbox(project: Project) {
-        val state = states[project] ?: return
-        if (state.ready) {
-            while (true) {
-                val m = state.outbox.poll() ?: break
-                sendRaw(project, m)
+    private fun parseQuery(query: String): Map<String, String> {
+        return query.split("&")
+            .filter { it.isNotEmpty() }
+            .associate { param ->
+                val parts = param.split("=", limit = 2)
+                val key = URLDecoder.decode(parts[0], "UTF-8")
+                val value = if (parts.size > 1) URLDecoder.decode(parts[1], "UTF-8") else ""
+                key to value
             }
-        }
     }
 }

+ 14 - 10
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeOpenFilesUpdater.kt

@@ -1,6 +1,5 @@
 package paviko.opencode.ui
 
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import com.intellij.openapi.Disposable
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.openapi.project.Project
@@ -12,8 +11,7 @@ import org.cef.handler.CefLifeSpanHandlerAdapter
 import java.nio.file.Paths
 import java.util.concurrent.ScheduledFuture
 
-class IdeOpenFilesUpdater(private val project: Project, private val browser: JBCefBrowser) : Disposable {
-    private val mapper = jacksonObjectMapper()
+class IdeOpenFilesUpdater(private val project: Project, private val browser: JBCefBrowser, private val sessionId: String) : Disposable {
     private var scheduled: ScheduledFuture<*>? = null
 
     fun install() {
@@ -25,14 +23,12 @@ class IdeOpenFilesUpdater(private val project: Project, private val browser: JBC
         fun pushAsync() {
             AppExecutorUtil.getAppExecutorService().execute {
                 try {
-                    // Get files on EDT (quick), compute paths on background thread
-                    val app = ApplicationManager.getApplication()
+                    // Snapshot open files on EDT like before.
                     val openFiles = mutableListOf<VirtualFile>()
                     var selectedFile: VirtualFile? = null
-                    
-                    // Collect files from EDT
+
                     val latch = java.util.concurrent.CountDownLatch(1)
-                    app.invokeLater {
+                    ApplicationManager.getApplication().invokeLater {
                         try {
                             openFiles.addAll(fem.openFiles)
                             selectedFile = fem.selectedEditor?.file
@@ -40,14 +36,22 @@ class IdeOpenFilesUpdater(private val project: Project, private val browser: JBC
                             latch.countDown()
                         }
                     }
-                    latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
+
+                    try {
+                        latch.await()
+                    } catch (_: InterruptedException) {
+                        Thread.currentThread().interrupt()
+                        return@execute
+                    }
+
+                    if (project.isDisposed) return@execute
                     
                     // Compute paths on background thread (NIO operations)
                     val opened = openFiles.mapNotNull { vf -> vfPath(vf) }
                     val current = selectedFile?.let { vf -> vfPath(vf) }
                     
                     // Send result (IdeBridge.send already handles threading)
-                    IdeBridge.send(project, "updateOpenedFiles", mapOf("openedFiles" to opened, "currentFile" to current))
+                    IdeBridge.send(sessionId, "updateOpenedFiles", mapOf("openedFiles" to opened, "currentFile" to current))
                 } catch (e: Exception) {
                     e.printStackTrace()
                 }

+ 0 - 9
hosts/vscode-plugin/resources/webview/index.html

@@ -285,15 +285,6 @@
             return
           }
 
-          // Forward ideBridge UI->host messages: child posts { type: '__ideBridgeSend', json }
-          if (message && message.type === "__ideBridgeSend" && typeof message.json === "string") {
-            try {
-              window.vscode.postMessage({ type: "__ideBridgeSend", json: message.json })
-            } catch (e) {
-              console.error("Failed to forward ideBridge message to VS Code:", e)
-            }
-            return
-          }
           // Handle other messages FROM iframe - forward to VS Code extension
           if (message && message.type) {
             try {

+ 37 - 51
hosts/vscode-plugin/src/ui/CommunicationBridge.ts

@@ -3,6 +3,7 @@ import * as path from "path"
 import { errorHandler } from "../utils/ErrorHandler"
 import { PluginCommunicator, UnifiedMessage } from "../types/UnifiedMessage"
 import { logger } from "../globals"
+import type { bridgeServer as BridgeServerType } from "./IdeBridgeServer"
 
 /**
  * Communication bridge between VSCode and WebUI
@@ -26,6 +27,8 @@ export class CommunicationBridge implements PluginCommunicator {
   private context?: vscode.ExtensionContext
   private onStateChange?: (key: string, value: any) => Promise<void>
   private messageHandlerDisposable?: vscode.Disposable
+  private _bridgeSessionId?: string
+  private _bridgeServer?: typeof BridgeServerType
 
   constructor(options: CommunicationBridgeOptions = {}) {
     this.webview = options.webview
@@ -73,6 +76,28 @@ export class CommunicationBridge implements PluginCommunicator {
     this.onStateChange = callback
   }
 
+  /**
+   * Set bridge session for routing ideBridge-type messages via SSE
+   * @param sessionId Bridge session ID
+   * @param server Bridge server instance
+   */
+  setBridgeSession(sessionId: string, server: typeof BridgeServerType): void {
+    this._bridgeSessionId = sessionId
+    this._bridgeServer = server
+    logger.appendLine(`Bridge session set: ${sessionId}`)
+  }
+
+  /**
+   * Send a message via the bridge server SSE (for ideBridge messages)
+   */
+  private sendViaBridge(type: string, payload: any): boolean {
+    if (this._bridgeSessionId && this._bridgeServer) {
+      this._bridgeServer.send(this._bridgeSessionId, { type, payload })
+      return true
+    }
+    return false
+  }
+
   // VSCode → WebUI communication methods
 
   /**
@@ -127,13 +152,13 @@ export class CommunicationBridge implements PluginCommunicator {
         return
       }
 
-      // Send unified message
-      this.sendMessage({
-        type: "insertPaths",
-        paths: validPaths,
-      })
+      // Route through bridge server SSE (required)
+      if (!this.sendViaBridge("insertPaths", { paths: validPaths })) {
+        logger.appendLine("Bridge session not set; cannot send insertPaths")
+        return
+      }
 
-      logger.appendLine(`Inserted ${validPaths.length} paths: ${validPaths.join(", ")}`)
+      logger.appendLine(`Inserted ${validPaths.length} paths via bridge: ${validPaths.join(", ")}`)
     } catch (error) {
       logger.appendLine(`Error inserting paths: ${error}`)
 
@@ -165,13 +190,13 @@ export class CommunicationBridge implements PluginCommunicator {
         return
       }
 
-      // Send unified message
-      this.sendMessage({
-        type: "pastePath",
-        path: normalizedPath,
-      })
+      // Route through bridge server SSE (required)
+      if (!this.sendViaBridge("pastePath", { path: normalizedPath })) {
+        logger.appendLine("Bridge session not set; cannot send pastePath")
+        return
+      }
 
-      logger.appendLine(`Pasted path: ${normalizedPath}`)
+      logger.appendLine(`Pasted path via bridge: ${normalizedPath}`)
     } catch (error) {
       logger.appendLine(`Error pasting path: ${error}`)
 
@@ -451,45 +476,6 @@ export class CommunicationBridge implements PluginCommunicator {
     this.messageHandlerDisposable = this.webview.onDidReceiveMessage(
       async (message) => {
         try {
-          // ideBridge JSON tunnel from iframe
-          if (message && message.type === "__ideBridgeSend" && typeof message.json === "string") {
-            try {
-              const m = JSON.parse(message.json)
-              if (m && m.type === "openFile") {
-                await this.handleOpenFile(m.payload?.path ?? m.path)
-                // reply if id present
-                if (m.id) {
-                  this.webview?.postMessage({ replyTo: m.id, ok: true })
-                }
-              } else if (m && m.type === "openUrl") {
-                await this.handleOpenUrl(m.payload?.url ?? m.url)
-                if (m.id) {
-                  this.webview?.postMessage({ replyTo: m.id, ok: true })
-                }
-              } else if (m && m.type === "reloadPath") {
-                await this.handleReloadPath(m.payload?.path)
-                if (m.id) {
-                  this.webview?.postMessage({ replyTo: m.id, ok: true })
-                }
-              } else {
-                // Generic ack for unknown types
-                if (m && m.id) this.webview?.postMessage({ replyTo: m.id, ok: true })
-              }
-            } catch (e) {
-              try {
-                const id = (() => {
-                  try {
-                    return JSON.parse(message.json).id
-                  } catch {
-                    return undefined
-                  }
-                })()
-                if (id) this.webview?.postMessage({ replyTo: id, ok: false, error: String(e) })
-              } catch {}
-              logger.appendLine(`Failed to process __ideBridgeSend: ${e}`)
-            }
-            return
-          }
           switch (message.type) {
             case "openFile":
               await this.handleOpenFile(message.path)

+ 297 - 0
hosts/vscode-plugin/src/ui/IdeBridgeServer.ts

@@ -0,0 +1,297 @@
+import * as http from "http"
+import * as crypto from "crypto"
+
+export interface SessionHandlers {
+  openFile: (path: string) => Promise<void>
+  openUrl: (url: string) => Promise<void>
+  reloadPath: (path: string) => Promise<void>
+}
+
+interface Session {
+  id: string
+  token: string
+  handlers: SessionHandlers
+  sseClients: Set<http.ServerResponse>
+}
+
+interface Message {
+  id?: string
+  replyTo?: string
+  type?: string
+  payload?: any
+  ok?: boolean
+  error?: string
+  timestamp: number
+}
+
+class IdeBridgeServer {
+  private server: http.Server | null = null
+  private port: number = 0
+  private sessions: Map<string, Session> = new Map()
+  private keepaliveInterval: NodeJS.Timeout | null = null
+
+  async start(): Promise<void> {
+    if (this.server) return
+
+    return new Promise((resolve, reject) => {
+      this.server = http.createServer((req, res) => this.handleRequest(req, res))
+      this.server.listen(0, "127.0.0.1", () => {
+        const addr = this.server!.address()
+        if (addr && typeof addr !== "string") {
+          this.port = addr.port
+          console.log(`IdeBridgeServer started on port ${this.port}`)
+          
+          // Start keepalive timer to prevent tunnel timeouts
+          if (!this.keepaliveInterval) {
+            this.keepaliveInterval = setInterval(() => this.sendKeepaliveToAll(), 15000)
+          }
+          
+          resolve()
+        } else {
+          reject(new Error("Failed to get server port"))
+        }
+      })
+      this.server.on("error", reject)
+    })
+  }
+
+  stop(): void {
+    if (this.keepaliveInterval) {
+      clearInterval(this.keepaliveInterval)
+      this.keepaliveInterval = null
+    }
+    this.server?.close()
+    this.server = null
+    this.sessions.clear()
+  }
+
+  async createSession(handlers: SessionHandlers): Promise<{ sessionId: string; baseUrl: string; token: string }> {
+    await this.start() // ensure server is running
+
+    const sessionId = crypto.randomUUID()
+    const token = crypto.randomUUID()
+
+    this.sessions.set(sessionId, {
+      id: sessionId,
+      token,
+      handlers,
+      sseClients: new Set(),
+    })
+
+    return {
+      sessionId,
+      baseUrl: `http://127.0.0.1:${this.port}/idebridge/${sessionId}`,
+      token,
+    }
+  }
+
+  removeSession(sessionId: string): void {
+    const session = this.sessions.get(sessionId)
+    if (session) {
+      // Close all SSE clients
+      session.sseClients.forEach((res) => res.end())
+      this.sessions.delete(sessionId)
+    }
+  }
+
+  send(sessionId: string, message: Omit<Message, "timestamp">): void {
+    const session = this.sessions.get(sessionId)
+    if (!session) return
+
+    const msg: Message = { ...message, timestamp: Date.now() }
+    this.broadcastSSE(session, JSON.stringify(msg))
+  }
+
+  private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+    // CORS headers
+    res.setHeader("Access-Control-Allow-Origin", "*")
+    res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+    res.setHeader("Access-Control-Allow-Headers", "Content-Type")
+
+    if (req.method === "OPTIONS") {
+      res.writeHead(204)
+      res.end()
+      return
+    }
+
+    // Parse URL: /idebridge/{sessionId}/{action}?token=...
+    const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`)
+    const pathParts = url.pathname.split("/").filter(Boolean)
+
+    if (pathParts.length < 3 || pathParts[0] !== "idebridge") {
+      res.writeHead(404)
+      res.end()
+      return
+    }
+
+    const sessionId = pathParts[1]
+    const action = pathParts[2]
+    const token = url.searchParams.get("token")
+    const session = this.sessions.get(sessionId)
+
+    if (!session || session.token !== token) {
+      res.writeHead(401)
+      res.end()
+      return
+    }
+
+    switch (action) {
+      case "events":
+        this.handleSSE(req, res, session)
+        break
+      case "send":
+        this.handleSend(req, res, session)
+        break
+      default:
+        res.writeHead(404)
+        res.end()
+    }
+  }
+
+  private handleSSE(req: http.IncomingMessage, res: http.ServerResponse, session: Session): void {
+    res.writeHead(200, {
+      "Content-Type": "text/event-stream",
+      "Cache-Control": "no-cache, no-transform",
+      Connection: "keep-alive",
+      "X-Accel-Buffering": "no", // Disable nginx/proxy buffering
+    })
+
+    session.sseClients.add(res)
+
+    // Send initial connected event
+    res.write("event: connected\ndata: {}\n\n")
+
+    // Handle client disconnect
+    req.on("close", () => {
+      session.sseClients.delete(res)
+    })
+  }
+
+  private async handleSend(req: http.IncomingMessage, res: http.ServerResponse, session: Session): Promise<void> {
+    if (req.method !== "POST") {
+      res.writeHead(405)
+      res.end()
+      return
+    }
+
+    try {
+      const body = await this.readBody(req)
+      const msg: Message = JSON.parse(body)
+
+      const { type, id, payload } = msg
+
+      switch (type) {
+        case "openFile":
+          if (payload?.path) {
+            await session.handlers.openFile(payload.path)
+            this.replyOk(session, id)
+          } else {
+            this.replyError(session, id, "Missing path")
+          }
+          break
+
+        case "openUrl":
+          if (payload?.url) {
+            await session.handlers.openUrl(payload.url)
+            this.replyOk(session, id)
+          } else {
+            this.replyError(session, id, "Missing url")
+          }
+          break
+
+        case "reloadPath":
+          if (payload?.path) {
+            await session.handlers.reloadPath(payload.path)
+            this.replyOk(session, id)
+          } else {
+            this.replyError(session, id, "Missing path")
+          }
+          break
+
+        default:
+          this.replyError(session, id, `Unknown type: ${type}`)
+      }
+
+      res.writeHead(204)
+    } catch (e) {
+      console.error("Error handling send:", e)
+      res.writeHead(400)
+    }
+    res.end()
+  }
+
+  private replyOk(session: Session, id?: string): void {
+    if (!id) return
+    this.broadcastSSE(
+      session,
+      JSON.stringify({
+        replyTo: id,
+        ok: true,
+        timestamp: Date.now(),
+      }),
+    )
+  }
+
+  private replyError(session: Session, id: string | undefined, error: string): void {
+    if (!id) return
+    this.broadcastSSE(
+      session,
+      JSON.stringify({
+        replyTo: id,
+        ok: false,
+        error,
+        timestamp: Date.now(),
+      }),
+    )
+  }
+
+  private sendKeepaliveToAll(): void {
+    this.sessions.forEach((session) => {
+      const deadClients: http.ServerResponse[] = []
+      session.sseClients.forEach((client) => {
+        try {
+          client.write(": ping\n\n")
+        } catch {
+          deadClients.push(client)
+        }
+      })
+      deadClients.forEach((client) => {
+        session.sseClients.delete(client)
+        try {
+          client.end()
+        } catch {}
+      })
+    })
+  }
+
+  private broadcastSSE(session: Session, json: string): void {
+    const deadClients: http.ServerResponse[] = []
+
+    session.sseClients.forEach((client) => {
+      try {
+        client.write(`event: message\ndata: ${json}\n\n`)
+      } catch {
+        deadClients.push(client)
+      }
+    })
+
+    deadClients.forEach((client) => {
+      session.sseClients.delete(client)
+      try {
+        client.end()
+      } catch {}
+    })
+  }
+
+  private readBody(req: http.IncomingMessage): Promise<string> {
+    return new Promise((resolve, reject) => {
+      let body = ""
+      req.on("data", (chunk) => (body += chunk))
+      req.on("end", () => resolve(body))
+      req.on("error", reject)
+    })
+  }
+}
+
+// Singleton instance
+export const bridgeServer = new IdeBridgeServer()

+ 50 - 3
hosts/vscode-plugin/src/ui/WebviewController.ts

@@ -6,6 +6,7 @@ import { FileMonitor } from "../utils/FileMonitor"
 import { errorHandler } from "../utils/ErrorHandler"
 import { PathInserter } from "../utils/PathInserter"
 import { logger } from "../globals"
+import { bridgeServer } from "./IdeBridgeServer"
 
 /**
  * Shared webview controller to manage common UI lifecycle and messaging
@@ -25,6 +26,7 @@ export class WebviewController {
   private fileMonitor?: FileMonitor
   private connection?: BackendConnection
   private disposables: vscode.Disposable[] = []
+  private bridgeSessionId: string | null = null
 
   constructor(opts: WebviewControllerOptions) {
     this.webview = opts.webview
@@ -57,12 +59,31 @@ export class WebviewController {
         PathInserter.setCommunicationBridge(this.communicationBridge)
       } catch {}
 
+      // Create bridge session with handlers from CommunicationBridge
+      const session = await bridgeServer.createSession({
+        openFile: (path) => this.communicationBridge!.handleOpenFile(path),
+        openUrl: (url) => this.communicationBridge!.handleOpenUrl(url),
+        reloadPath: (path) => this.communicationBridge!.handleReloadPath(path),
+      })
+      this.bridgeSessionId = session.sessionId
+      
+      // Tell CommunicationBridge to route ideBridge messages through SSE
+      this.communicationBridge.setBridgeSession(session.sessionId, bridgeServer)
+
       // Initialize file monitor (best effort)
       try {
         this.fileMonitor = new FileMonitor()
         this.fileMonitor.startMonitoring((files: string[], current?: string) => {
           try {
-            this.communicationBridge?.updateOpenedFiles(files, current)
+            if (this.bridgeSessionId) {
+              // Normalize paths for cross-platform consistency (especially Windows)
+              const normalizedFiles = files.map(f => this.normalizePath(f)).filter((f): f is string => f !== null)
+              const normalizedCurrent = current ? this.normalizePath(current) : undefined
+              bridgeServer.send(this.bridgeSessionId, {
+                type: "updateOpenedFiles",
+                payload: { openedFiles: normalizedFiles, currentFile: normalizedCurrent },
+              })
+            }
           } catch (e) {
             logger.appendLine(`updateOpenedFiles failed: ${e}`)
           }
@@ -71,8 +92,15 @@ export class WebviewController {
         logger.appendLine(`FileMonitor init failed: ${e}`)
       }
 
-      const urlWithMode = this.buildUiUrlWithMode(connection.uiBase)
-      const html = await this.generateHtmlContent(urlWithMode)
+      // Use asExternalUri for Remote-SSH compatibility
+      const externalUi = await vscode.env.asExternalUri(vscode.Uri.parse(connection.uiBase))
+      const externalBridge = await vscode.env.asExternalUri(vscode.Uri.parse(session.baseUrl))
+
+      // Build iframe src with bridge params
+      const uiUrlWithMode = this.buildUiUrlWithMode(externalUi.toString())
+      const iframeSrc = `${uiUrlWithMode}&ideBridge=${encodeURIComponent(externalBridge.toString())}&ideBridgeToken=${encodeURIComponent(session.token)}`
+
+      const html = await this.generateHtmlContent(iframeSrc)
       this.webview.html = html
 
       // Message handling is now done entirely by CommunicationBridge
@@ -195,6 +223,21 @@ export class WebviewController {
     return html
   }
 
+  private normalizePath(rawPath: string): string | null {
+    try {
+      if (!rawPath || rawPath.trim().length === 0) return null
+      let p = rawPath.trim()
+      if (p.startsWith("file://")) {
+        p = vscode.Uri.parse(p).fsPath
+      }
+      // Normalize and convert to POSIX style for consistency
+      const path = require("path")
+      return path.normalize(p).split(path.sep).join("/")
+    } catch {
+      return null
+    }
+  }
+
   dispose(): void {
     try {
       this.fileMonitor?.stopMonitoring()
@@ -205,6 +248,10 @@ export class WebviewController {
     try {
       PathInserter.clearCommunicationBridge()
     } catch {}
+    if (this.bridgeSessionId) {
+      bridgeServer.removeSession(this.bridgeSessionId)
+      this.bridgeSessionId = null
+    }
     for (const d of this.disposables) {
       try {
         d.dispose()

+ 99 - 47
packages/opencode/webgui/src/lib/ideBridge.ts

@@ -10,31 +10,67 @@ type Message = {
 
 type Handler = (message: Message) => void
 
+// Parse URL params once at module load
+const params = new URLSearchParams(window.location.search)
+const bridgeBase = params.get("ideBridge")
+const token = params.get("ideBridgeToken")
+
 class IdeBridge {
   ready = false
-  private queue: string[] = []
+  private queue: Message[] = []
   private handlers: Set<Handler> = new Set()
   private pending = new Map<string, { resolve: (m: Message) => void; reject: (e: any) => void }>()
-  private flushTimer: number | null = null
+  private eventSource: EventSource | null = null
+  private reconnectDelay = 1000
+  private readonly maxReconnectDelay = 30000
+  private reconnectScheduled = false
 
   isInstalled(): boolean {
-    return typeof (window as any).__ideBridgeSend === "function" || (window.parent && window.parent !== window)
+    return !!(bridgeBase && token)
   }
 
   init() {
-    const onMessage = (ev: MessageEvent) => {
-      const msg = ev.data as Message
-      this.dispatch(msg)
+    this.connect()
+  }
+
+  private connect() {
+    if (!bridgeBase || !token) return
+
+    this.eventSource = new EventSource(`${bridgeBase}/events?token=${encodeURIComponent(token)}`)
+
+    this.eventSource.onopen = () => {
+      this.ready = true
+      this.reconnectDelay = 1000
+      this.flushQueue()
     }
-    window.addEventListener("message", onMessage)
-    ;(window as any).__ideBridgeOnMessage = (m: any) => {
+
+    this.eventSource.onmessage = (ev) => {
       try {
-        this.dispatch(typeof m === "string" ? JSON.parse(m) : m)
-      } catch {}
-      if (!this.ready) this.flush()
+        const msg = JSON.parse(ev.data) as Message
+        this.dispatch(msg)
+      } catch (e) {
+        console.warn("[ideBridge] Failed to parse SSE message:", e)
+      }
+    }
+
+    this.eventSource.onerror = () => {
+      this.ready = false
+      this.scheduleReconnect()
     }
   }
 
+  private scheduleReconnect() {
+    if (this.reconnectScheduled) return
+    this.reconnectScheduled = true
+    this.eventSource?.close()
+    this.eventSource = null
+    setTimeout(() => {
+      this.reconnectScheduled = false
+      this.connect()
+    }, this.reconnectDelay)
+    this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
+  }
+
   private dispatch(msg: Message) {
     if (msg && msg.replyTo) {
       const p = this.pending.get(msg.replyTo)
@@ -54,29 +90,68 @@ class IdeBridge {
   on(handler: Handler) {
     this.handlers.add(handler)
   }
+
   off(handler: Handler) {
     this.handlers.delete(handler)
   }
 
   send(msg: Message) {
-    const s = typeof msg === "string" ? String(msg) : JSON.stringify(msg)
-    const fn = (window as any).__ideBridgeSend
-    if (typeof fn === "function") {
-      fn(s)
+    if (!bridgeBase || !token) {
+      console.warn("[ideBridge] Bridge not configured, ignoring send:", msg.type)
+      return
+    }
+
+    if (!this.ready) {
+      this.queue.push(msg)
       return
     }
+
+    this.doSend(msg)
+  }
+
+  private async doSend(msg: Message, retryCount = 0) {
+    if (!bridgeBase || !token) return
+
     try {
-      if (window.parent && window.parent !== window) {
-        window.parent.postMessage({ type: "__ideBridgeSend", json: s }, "*")
-        return
+      const response = await fetch(`${bridgeBase}/send?token=${encodeURIComponent(token)}`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(msg),
+      })
+
+      if (!response.ok) {
+        console.warn("[ideBridge] Send failed with status:", response.status)
+        // Requeue on server errors (5xx) with limited retries
+        if (response.status >= 500 && retryCount < 3) {
+          this.requeueWithBackoff(msg, retryCount)
+        }
+      }
+    } catch (e) {
+      console.warn("[ideBridge] Send failed:", e)
+      // Network error - requeue with backoff
+      if (retryCount < 3) {
+        this.requeueWithBackoff(msg, retryCount)
       }
-    } catch {}
-    this.queue.push(s)
-    this.ensureFlushScheduled()
+    }
+  }
+
+  private requeueWithBackoff(msg: Message, retryCount: number) {
+    const delay = Math.min(1000 * Math.pow(2, retryCount), 10000)
+    setTimeout(() => {
+      if (this.ready) {
+        this.doSend(msg, retryCount + 1)
+      } else {
+        this.queue.push(msg)
+      }
+    }, delay)
   }
 
   request<T = any>(type: string, payload?: any): Promise<Message & { result?: T }> {
     return new Promise((resolve, reject) => {
+      if (!this.isInstalled()) {
+        reject(new Error("[ideBridge] Bridge not installed"))
+        return
+      }
       try {
         const id = String(Date.now()) + Math.random().toString(36).slice(2)
         this.pending.set(id, { resolve, reject })
@@ -87,33 +162,10 @@ class IdeBridge {
     })
   }
 
-  private ensureFlushScheduled() {
-    if (this.flushTimer !== null) return
-    const attempt = () => {
-      const fn = (window as any).__ideBridgeSend
-      if (typeof fn === "function") {
-        this.flush()
-        if (this.flushTimer !== null) {
-          window.clearTimeout(this.flushTimer)
-          this.flushTimer = null
-        }
-        return
-      }
-      this.flushTimer = window.setTimeout(attempt, 100)
-    }
-    this.flushTimer = window.setTimeout(attempt, 0)
-  }
-
-  flush() {
-    this.ready = true
+  private flushQueue() {
     const q = this.queue.splice(0, this.queue.length)
-    const fn = (window as any).__ideBridgeSend
-    for (const s of q) {
-      try {
-        if (typeof fn === "function") fn(s)
-        else if (window.parent && window.parent !== window)
-          window.parent.postMessage({ type: "__ideBridgeSend", json: s }, "*")
-      } catch {}
+    for (const msg of q) {
+      this.doSend(msg)
     }
   }
 }