package main import ( "context" "encoding/json" "fmt" "net" "net/http" "os" "os/exec" "runtime" "sync" "time" ) // WebServer manages SSE connections for real-time output streaming type WebServer struct { mu sync.RWMutex clients map[string][]chan ContentEvent sessions map[string]*SessionState server *http.Server port int backend string // Current backend name for single-panel display } // SessionState tracks a running session type SessionState struct { ID string `json:"id"` Backend string `json:"backend"` Task string `json:"task"` StartTime time.Time `json:"start_time"` Content string `json:"content"` Done bool `json:"done"` } // ContentEvent is sent to SSE clients type ContentEvent struct { SessionID string `json:"session_id"` Backend string `json:"backend"` Content string `json:"content,omitempty"` ContentType string `json:"content_type,omitempty"` // "reasoning", "command", "message" Done bool `json:"done,omitempty"` } // NewWebServer creates a new web server func NewWebServer(backend string) *WebServer { return &WebServer{ clients: make(map[string][]chan ContentEvent), sessions: make(map[string]*SessionState), backend: backend, } } // Start starts the web server on a random available port func (ws *WebServer) Start() error { mux := http.NewServeMux() // Serve the main page mux.HandleFunc("/", ws.handleIndex) // API endpoints mux.HandleFunc("/api/sessions", ws.handleSessions) mux.HandleFunc("/api/stream/", ws.handleStream) // Listen on port 0 to get a random available port listener, err := net.Listen("tcp", ":0") if err != nil { return err } ws.port = listener.Addr().(*net.TCPAddr).Port ws.server = &http.Server{ Handler: mux, } url := fmt.Sprintf("http://localhost:%d", ws.port) fmt.Fprintf(os.Stderr, " Web UI: %s\n", url) go func() { if err := ws.server.Serve(listener); err != nil && err != http.ErrServerClosed { logWarn(fmt.Sprintf("Web server error: %v", err)) } }() // Auto-open browser go openBrowser(url) return nil } // Stop gracefully shuts down the web server func (ws *WebServer) Stop() error { if ws == nil || ws.server == nil { return nil } // Create a context with a short timeout for graceful shutdown ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // Close all client connections ws.mu.Lock() for _, clients := range ws.clients { for _, ch := range clients { close(ch) } } ws.clients = make(map[string][]chan ContentEvent) ws.mu.Unlock() return ws.server.Shutdown(ctx) } // openBrowser opens the specified URL in the default browser func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("open", url) case "linux": cmd = exec.Command("xdg-open", url) case "windows": // Use rundll32 for better compatibility cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) // Hide CMD window on Windows hideWindowsConsole(cmd) default: return } // FIX: Properly clean up browser process to prevent zombie processes // Start the command and wait for it to complete in a goroutine if err := cmd.Start(); err != nil { // Silently fail if browser can't be opened return } // Clean up process in background to prevent zombie processes go func() { _ = cmd.Wait() }() } // StartSession starts tracking a new session func (ws *WebServer) StartSession(id, backend, task string) { ws.mu.Lock() defer ws.mu.Unlock() ws.sessions[id] = &SessionState{ ID: id, Backend: backend, Task: task, StartTime: time.Now(), } } // SendContent sends content to all subscribers of a session func (ws *WebServer) SendContent(sessionID, backend, content string) { ws.SendContentWithType(sessionID, backend, content, "message") } // SendContentWithType sends content with a specific type to all subscribers func (ws *WebServer) SendContentWithType(sessionID, backend, content, contentType string) { ws.mu.Lock() defer ws.mu.Unlock() // Update session state if session, ok := ws.sessions[sessionID]; ok { session.Content += content } // Send to all subscribers event := ContentEvent{ SessionID: sessionID, Backend: backend, Content: content, ContentType: contentType, } for _, ch := range ws.clients[sessionID] { select { case ch <- event: default: // Skip if channel is full } } } // EndSession marks a session as complete func (ws *WebServer) EndSession(sessionID, backend string) { ws.mu.Lock() defer ws.mu.Unlock() if session, ok := ws.sessions[sessionID]; ok { session.Done = true } event := ContentEvent{ SessionID: sessionID, Backend: backend, Done: true, } for _, ch := range ws.clients[sessionID] { select { case ch <- event: default: } } } // handleIndex serves the main HTML page func (ws *WebServer) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } // Generate single-panel HTML based on backend html := ws.generateIndexHTML() w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } // generateIndexHTML creates a single-panel HTML page for the current backend func (ws *WebServer) generateIndexHTML() string { backend := ws.backend if backend == "" { backend = "Agent" } // Determine colors based on backend iconBg := "#238636" titleColor := "#3fb950" iconText := "AGT" switch backend { case "codex": iconBg = "#238636" titleColor = "#3fb950" iconText = "CDX" case "gemini": iconBg = "#8957e5" titleColor = "#a371f7" iconText = "GEM" case "claude": iconBg = "#d97706" titleColor = "#fbbf24" iconText = "CLD" } return fmt.Sprintf(` %s - Live Output
%s
%s
LIVE
`, backend, iconBg, titleColor, iconText, backend) } // handleSessions returns all active sessions func (ws *WebServer) handleSessions(w http.ResponseWriter, r *http.Request) { ws.mu.RLock() sessions := make([]*SessionState, 0, len(ws.sessions)) for _, s := range ws.sessions { sessions = append(sessions, s) } ws.mu.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(sessions) } // handleStream handles SSE connections for a session func (ws *WebServer) handleStream(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Path[len("/api/stream/"):] if sessionID == "" { http.Error(w, "Session ID required", http.StatusBadRequest) return } // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return } // Create channel for this client ch := make(chan ContentEvent, 100) // Register client ws.mu.Lock() ws.clients[sessionID] = append(ws.clients[sessionID], ch) // Only send done state if session is already complete (no historical content) if session, ok := ws.sessions[sessionID]; ok && session.Done { ch <- ContentEvent{ SessionID: sessionID, Backend: session.Backend, Done: true, } } ws.mu.Unlock() // Cleanup on disconnect defer func() { ws.mu.Lock() clients := ws.clients[sessionID] for i, c := range clients { if c == ch { ws.clients[sessionID] = append(clients[:i], clients[i+1:]...) break } } ws.mu.Unlock() close(ch) }() // Stream events for { select { case event := <-ch: data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() if event.Done { return } case <-r.Context().Done(): return } } }