blob: 56fbdecc35c8417ecca3fa6e78ecaf183ce59065 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
Sean McCulloughbaa2b592025-04-23 10:40:08 -07005 "context"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "html"
10 "io"
11 "io/fs"
12 "log/slog"
13 "net/http"
14 "net/http/pprof"
15 "os"
16 "os/exec"
17 "strconv"
18 "strings"
19 "sync"
20 "syscall"
21 "time"
22
Philip Zeyliger176de792025-04-21 12:25:18 -070023 "sketch.dev/loop/server/gzhandler"
24
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "github.com/creack/pty"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070026 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070027 "sketch.dev/loop"
Philip Zeyliger2032b1c2025-04-23 19:40:42 -070028 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070029)
30
31// terminalSession represents a terminal session with its PTY and the event channel
32type terminalSession struct {
33 pty *os.File
34 eventsClients map[chan []byte]bool
35 lastEventClientID int
36 eventsClientsMutex sync.Mutex
37 cmd *exec.Cmd
38}
39
40// TerminalMessage represents a message sent from the client for terminal resize events
41type TerminalMessage struct {
42 Type string `json:"type"`
43 Cols uint16 `json:"cols"`
44 Rows uint16 `json:"rows"`
45}
46
47// TerminalResponse represents the response for a new terminal creation
48type TerminalResponse struct {
49 SessionID string `json:"sessionId"`
50}
51
Sean McCulloughd9f13372025-04-21 15:08:49 -070052type State struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070053 MessageCount int `json:"message_count"`
54 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
55 InitialCommit string `json:"initial_commit"`
56 Title string `json:"title"`
57 BranchName string `json:"branch_name,omitempty"`
58 Hostname string `json:"hostname"` // deprecated
59 WorkingDir string `json:"working_dir"` // deprecated
60 OS string `json:"os"` // deprecated
61 GitOrigin string `json:"git_origin,omitempty"`
62 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
63 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
64 SessionID string `json:"session_id"`
65 SSHAvailable bool `json:"ssh_available"`
66 SSHError string `json:"ssh_error,omitempty"`
67 InContainer bool `json:"in_container"`
68 FirstMessageIndex int `json:"first_message_index"`
69 AgentState string `json:"agent_state,omitempty"`
70 OutsideHostname string `json:"outside_hostname,omitempty"`
71 InsideHostname string `json:"inside_hostname,omitempty"`
72 OutsideOS string `json:"outside_os,omitempty"`
73 InsideOS string `json:"inside_os,omitempty"`
74 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
75 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Sean McCulloughd9f13372025-04-21 15:08:49 -070076}
77
Sean McCulloughbaa2b592025-04-23 10:40:08 -070078type InitRequest struct {
79 HostAddr string `json:"host_addr"`
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000080 OutsideHTTP string `json:"outside_http"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -070081 GitRemoteAddr string `json:"git_remote_addr"`
82 Commit string `json:"commit"`
83 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
84 SSHServerIdentity []byte `json:"ssh_server_identity"`
Philip Zeyligerc72fff52025-04-29 20:17:54 +000085 SSHAvailable bool `json:"ssh_available"`
86 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -070087}
88
Earl Lee2e463fb2025-04-17 11:22:22 -070089// Server serves sketch HTTP. Server implements http.Handler.
90type Server struct {
91 mux *http.ServeMux
92 agent loop.CodingAgent
93 hostname string
94 logFile *os.File
95 // Mutex to protect terminalSessions
96 ptyMutex sync.Mutex
97 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +000098 sshAvailable bool
99 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700100}
101
102func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
103 s.mux.ServeHTTP(w, r)
104}
105
106// New creates a new HTTP server.
107func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
108 s := &Server{
109 mux: http.NewServeMux(),
110 agent: agent,
111 hostname: getHostname(),
112 logFile: logFile,
113 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000114 sshAvailable: false,
115 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700116 }
117
118 webBundle, err := webui.Build()
119 if err != nil {
120 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
121 }
122
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000123 s.mux.HandleFunc("/stream", s.handleSSEStream)
Earl Lee2e463fb2025-04-17 11:22:22 -0700124 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
125 // Check if a specific commit hash was requested
126 commit := r.URL.Query().Get("commit")
127
128 // Get the diff, optionally for a specific commit
129 var diff string
130 var err error
131 if commit != "" {
132 // Validate the commit hash format
133 if !isValidGitSHA(commit) {
134 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
135 return
136 }
137
138 diff, err = agent.Diff(&commit)
139 } else {
140 diff, err = agent.Diff(nil)
141 }
142
143 if err != nil {
144 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
145 return
146 }
147
148 w.Header().Set("Content-Type", "text/plain")
149 w.Write([]byte(diff))
150 })
151
152 // Handler for initialization called by host sketch binary when inside docker.
153 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
154 defer func() {
155 if err := recover(); err != nil {
156 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
157
158 // Return an error response to the client
159 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
160 }
161 }()
162
163 if r.Method != "POST" {
164 http.Error(w, "POST required", http.StatusBadRequest)
165 return
166 }
167
168 body, err := io.ReadAll(r.Body)
169 r.Body.Close()
170 if err != nil {
171 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
172 return
173 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700174
175 m := &InitRequest{}
176 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
178 return
179 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700180
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000181 // Store SSH availability info
182 s.sshAvailable = m.SSHAvailable
183 s.sshError = m.SSHError
184
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700185 // Start the SSH server if the init request included ssh keys.
186 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
187 go func() {
188 ctx := context.Background()
189 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys); err != nil {
190 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000191 // Update SSH error if server fails to start
192 s.sshAvailable = false
193 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700194 }
195 }()
196 }
197
Earl Lee2e463fb2025-04-17 11:22:22 -0700198 ini := loop.AgentInit{
199 WorkingDir: "/app",
200 InDocker: true,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700201 Commit: m.Commit,
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000202 OutsideHTTP: m.OutsideHTTP,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700203 GitRemoteAddr: m.GitRemoteAddr,
204 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 }
206 if err := agent.Init(ini); err != nil {
207 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
208 return
209 }
210 w.Header().Set("Content-Type", "application/json")
211 io.WriteString(w, "{}\n")
212 })
213
214 // Handler for /messages?start=N&end=M (start/end are optional)
215 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
216 w.Header().Set("Content-Type", "application/json")
217
218 // Extract query parameters for range
219 var start, end int
220 var err error
221
222 currentCount := agent.MessageCount()
223
224 startParam := r.URL.Query().Get("start")
225 if startParam != "" {
226 start, err = strconv.Atoi(startParam)
227 if err != nil {
228 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
229 return
230 }
231 }
232
233 endParam := r.URL.Query().Get("end")
234 if endParam != "" {
235 end, err = strconv.Atoi(endParam)
236 if err != nil {
237 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
238 return
239 }
240 } else {
241 end = currentCount
242 }
243
244 if start < 0 || start > end || end > currentCount {
245 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
246 return
247 }
248
249 start = max(0, start)
250 end = min(agent.MessageCount(), end)
251 messages := agent.Messages(start, end)
252
253 // Create a JSON encoder with indentation for pretty-printing
254 encoder := json.NewEncoder(w)
255 encoder.SetIndent("", " ") // Two spaces for each indentation level
256
257 err = encoder.Encode(messages)
258 if err != nil {
259 http.Error(w, err.Error(), http.StatusInternalServerError)
260 }
261 })
262
263 // Handler for /logs - displays the contents of the log file
264 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
265 if s.logFile == nil {
266 http.Error(w, "log file not set", http.StatusNotFound)
267 return
268 }
269 logContents, err := os.ReadFile(s.logFile.Name())
270 if err != nil {
271 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
272 return
273 }
274 w.Header().Set("Content-Type", "text/html; charset=utf-8")
275 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
276 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
277 fmt.Fprintf(w, "</body>\n</html>")
278 })
279
280 // Handler for /download - downloads both messages and status as a JSON file
281 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
282 // Set headers for file download
283 w.Header().Set("Content-Type", "application/octet-stream")
284
285 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
286 timestamp := time.Now().Format("20060102-150405")
287 filename := fmt.Sprintf("sketch-%s.json", timestamp)
288
289 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
290
291 // Get all messages
292 messageCount := agent.MessageCount()
293 messages := agent.Messages(0, messageCount)
294
295 // Get status information (usage and other metadata)
296 totalUsage := agent.TotalUsage()
297 hostname := getHostname()
298 workingDir := getWorkingDir()
299
300 // Create a combined structure with all information
301 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700302 Messages []loop.AgentMessage `json:"messages"`
303 MessageCount int `json:"message_count"`
304 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
305 Hostname string `json:"hostname"`
306 WorkingDir string `json:"working_dir"`
307 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 }{
309 Messages: messages,
310 MessageCount: messageCount,
311 TotalUsage: totalUsage,
312 Hostname: hostname,
313 WorkingDir: workingDir,
314 DownloadTime: time.Now().Format(time.RFC3339),
315 }
316
317 // Marshal the JSON with indentation for better readability
318 jsonData, err := json.MarshalIndent(downloadData, "", " ")
319 if err != nil {
320 http.Error(w, err.Error(), http.StatusInternalServerError)
321 return
322 }
323 w.Write(jsonData)
324 })
325
326 // The latter doesn't return until the number of messages has changed (from seen
327 // or from when this was called.)
328 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
329 pollParam := r.URL.Query().Get("poll")
330 seenParam := r.URL.Query().Get("seen")
331
332 // Get the client's current message count (if provided)
333 clientMessageCount := -1
334 var err error
335 if seenParam != "" {
336 clientMessageCount, err = strconv.Atoi(seenParam)
337 if err != nil {
338 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
339 return
340 }
341 }
342
343 serverMessageCount := agent.MessageCount()
344
345 // Let lazy clients not have to specify this.
346 if clientMessageCount == -1 {
347 clientMessageCount = serverMessageCount
348 }
349
350 if pollParam == "true" {
351 ch := make(chan string)
352 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700353 it := agent.NewIterator(r.Context(), clientMessageCount)
354 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700355 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700356 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700357 }()
358 select {
359 case <-r.Context().Done():
360 slog.DebugContext(r.Context(), "abandoned poll request")
361 return
362 case <-time.After(90 * time.Second):
363 // Let the user call /state again to get the latest to limit how long our long polls hang out.
364 slog.DebugContext(r.Context(), "longish poll request")
365 break
366 case <-ch:
367 break
368 }
369 }
370
Earl Lee2e463fb2025-04-17 11:22:22 -0700371 w.Header().Set("Content-Type", "application/json")
372
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000373 // Use the shared getState function
374 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700375
376 // Create a JSON encoder with indentation for pretty-printing
377 encoder := json.NewEncoder(w)
378 encoder.SetIndent("", " ") // Two spaces for each indentation level
379
380 err = encoder.Encode(state)
381 if err != nil {
382 http.Error(w, err.Error(), http.StatusInternalServerError)
383 }
384 })
385
Philip Zeyliger176de792025-04-21 12:25:18 -0700386 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700387
388 // Terminal WebSocket handler
389 // Terminal endpoints - predefined terminals 1-9
390 // TODO: The UI doesn't actually know how to use terminals 2-9!
391 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
392 if r.Method != http.MethodGet {
393 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
394 return
395 }
396 pathParts := strings.Split(r.URL.Path, "/")
397 if len(pathParts) < 4 {
398 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
399 return
400 }
401
402 sessionID := pathParts[3]
403 // Validate that the terminal ID is between 1-9
404 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
405 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
406 return
407 }
408
409 s.handleTerminalEvents(w, r, sessionID)
410 })
411
412 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
413 if r.Method != http.MethodPost {
414 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
415 return
416 }
417 pathParts := strings.Split(r.URL.Path, "/")
418 if len(pathParts) < 4 {
419 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
420 return
421 }
422 sessionID := pathParts[3]
423 s.handleTerminalInput(w, r, sessionID)
424 })
425
426 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700427 // Serve the sketch-app-shell.html file directly from the embedded filesystem
428 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700429 if err != nil {
430 http.Error(w, "File not found", http.StatusNotFound)
431 return
432 }
433 w.Header().Set("Content-Type", "text/html")
434 w.Write(data)
435 })
436
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700437 // Handler for POST /restart - restarts the conversation
438 s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
439 if r.Method != http.MethodPost {
440 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
441 return
442 }
443
444 // Parse the request body
445 var requestBody struct {
446 Revision string `json:"revision"`
447 InitialPrompt string `json:"initial_prompt"`
448 }
449
450 decoder := json.NewDecoder(r.Body)
451 if err := decoder.Decode(&requestBody); err != nil {
452 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
453 return
454 }
455 defer r.Body.Close()
456
457 // Call the restart method
458 err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
459 if err != nil {
460 http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
461 return
462 }
463
464 // Return success response
465 w.Header().Set("Content-Type", "application/json")
466 json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
467 })
468
469 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
470 // Handler for /commit-description - returns the description of a git commit
471 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
472 if r.Method != http.MethodGet {
473 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
474 return
475 }
476
477 // Get the revision parameter
478 revision := r.URL.Query().Get("revision")
479 if revision == "" {
480 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
481 return
482 }
483
484 // Run git command to get commit description
485 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
486 // Use the working directory from the agent
487 cmd.Dir = s.agent.WorkingDir()
488
489 output, err := cmd.CombinedOutput()
490 if err != nil {
491 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
492 return
493 }
494
495 // Prepare the response
496 resp := map[string]string{
497 "description": strings.TrimSpace(string(output)),
498 }
499
500 w.Header().Set("Content-Type", "application/json")
501 if err := json.NewEncoder(w).Encode(resp); err != nil {
502 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
503 }
504 })
505
506 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
507 s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
508 if r.Method != http.MethodGet {
509 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
510 return
511 }
512
513 // Call the suggest reprompt method
514 suggestedPrompt, err := agent.SuggestReprompt(r.Context())
515 if err != nil {
516 http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
517 return
518 }
519
520 // Return success response
521 w.Header().Set("Content-Type", "application/json")
522 json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
523 })
524
Earl Lee2e463fb2025-04-17 11:22:22 -0700525 // Handler for POST /chat
526 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
527 if r.Method != http.MethodPost {
528 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
529 return
530 }
531
532 // Parse the request body
533 var requestBody struct {
534 Message string `json:"message"`
535 }
536
537 decoder := json.NewDecoder(r.Body)
538 if err := decoder.Decode(&requestBody); err != nil {
539 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
540 return
541 }
542 defer r.Body.Close()
543
544 if requestBody.Message == "" {
545 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
546 return
547 }
548
549 agent.UserMessage(r.Context(), requestBody.Message)
550
551 w.WriteHeader(http.StatusOK)
552 })
553
554 // Handler for /cancel - cancels the current inner loop in progress
555 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
556 if r.Method != http.MethodPost {
557 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
558 return
559 }
560
561 // Parse the request body (optional)
562 var requestBody struct {
563 Reason string `json:"reason"`
564 ToolCallID string `json:"tool_call_id"`
565 }
566
567 decoder := json.NewDecoder(r.Body)
568 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
569 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
570 return
571 }
572 defer r.Body.Close()
573
574 cancelReason := "user requested cancellation"
575 if requestBody.Reason != "" {
576 cancelReason = requestBody.Reason
577 }
578
579 if requestBody.ToolCallID != "" {
580 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
581 if err != nil {
582 http.Error(w, err.Error(), http.StatusBadRequest)
583 return
584 }
585 // Return a success response
586 w.Header().Set("Content-Type", "application/json")
587 json.NewEncoder(w).Encode(map[string]string{
588 "status": "cancelled",
589 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700590 "reason": cancelReason,
591 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700592 return
593 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000594 // Call the CancelTurn method
595 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700596 // Return a success response
597 w.Header().Set("Content-Type", "application/json")
598 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
599 })
600
601 debugMux := initDebugMux()
602 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
603 debugMux.ServeHTTP(w, r)
604 })
605
606 return s, nil
607}
608
609// Utility functions
610func getHostname() string {
611 hostname, err := os.Hostname()
612 if err != nil {
613 return "unknown"
614 }
615 return hostname
616}
617
618func getWorkingDir() string {
619 wd, err := os.Getwd()
620 if err != nil {
621 return "unknown"
622 }
623 return wd
624}
625
626// createTerminalSession creates a new terminal session with the given ID
627func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
628 // Start a new shell process
629 shellPath := getShellPath()
630 cmd := exec.Command(shellPath)
631
632 // Get working directory from the agent if possible
633 workDir := getWorkingDir()
634 cmd.Dir = workDir
635
636 // Set up environment
637 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
638
639 // Start the command with a pty
640 ptmx, err := pty.Start(cmd)
641 if err != nil {
642 slog.Error("Failed to start pty", "error", err)
643 return nil, err
644 }
645
646 // Create the terminal session
647 session := &terminalSession{
648 pty: ptmx,
649 eventsClients: make(map[chan []byte]bool),
650 cmd: cmd,
651 }
652
653 // Start goroutine to read from pty and broadcast to all connected SSE clients
654 go s.readFromPtyAndBroadcast(sessionID, session)
655
656 return session, nil
657} // handleTerminalEvents handles SSE connections for terminal output
658func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
659 // Check if the session exists, if not, create it
660 s.ptyMutex.Lock()
661 session, exists := s.terminalSessions[sessionID]
662
663 if !exists {
664 // Create a new terminal session
665 var err error
666 session, err = s.createTerminalSession(sessionID)
667 if err != nil {
668 s.ptyMutex.Unlock()
669 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
670 return
671 }
672
673 // Store the new session
674 s.terminalSessions[sessionID] = session
675 }
676 s.ptyMutex.Unlock()
677
678 // Set headers for SSE
679 w.Header().Set("Content-Type", "text/event-stream")
680 w.Header().Set("Cache-Control", "no-cache")
681 w.Header().Set("Connection", "keep-alive")
682 w.Header().Set("Access-Control-Allow-Origin", "*")
683
684 // Create a channel for this client
685 events := make(chan []byte, 4096) // Buffer to prevent blocking
686
687 // Register this client's channel
688 session.eventsClientsMutex.Lock()
689 clientID := session.lastEventClientID + 1
690 session.lastEventClientID = clientID
691 session.eventsClients[events] = true
692 session.eventsClientsMutex.Unlock()
693
694 // When the client disconnects, remove their channel
695 defer func() {
696 session.eventsClientsMutex.Lock()
697 delete(session.eventsClients, events)
698 close(events)
699 session.eventsClientsMutex.Unlock()
700 }()
701
702 // Flush to send headers to client immediately
703 if f, ok := w.(http.Flusher); ok {
704 f.Flush()
705 }
706
707 // Send events to the client as they arrive
708 for {
709 select {
710 case <-r.Context().Done():
711 return
712 case data := <-events:
713 // Format as SSE with base64 encoding
714 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
715
716 // Flush the data immediately
717 if f, ok := w.(http.Flusher); ok {
718 f.Flush()
719 }
720 }
721 }
722}
723
724// handleTerminalInput processes input to the terminal
725func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
726 // Check if the session exists
727 s.ptyMutex.Lock()
728 session, exists := s.terminalSessions[sessionID]
729 s.ptyMutex.Unlock()
730
731 if !exists {
732 http.Error(w, "Terminal session not found", http.StatusNotFound)
733 return
734 }
735
736 // Read the request body (terminal input or resize command)
737 body, err := io.ReadAll(r.Body)
738 if err != nil {
739 http.Error(w, "Failed to read request body", http.StatusBadRequest)
740 return
741 }
742
743 // Check if it's a resize message
744 if len(body) > 0 && body[0] == '{' {
745 var msg TerminalMessage
746 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
747 if msg.Cols > 0 && msg.Rows > 0 {
748 pty.Setsize(session.pty, &pty.Winsize{
749 Cols: msg.Cols,
750 Rows: msg.Rows,
751 })
752
753 // Respond with success
754 w.WriteHeader(http.StatusOK)
755 return
756 }
757 }
758 }
759
760 // Regular terminal input
761 _, err = session.pty.Write(body)
762 if err != nil {
763 slog.Error("Failed to write to pty", "error", err)
764 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
765 return
766 }
767
768 // Respond with success
769 w.WriteHeader(http.StatusOK)
770}
771
772// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
773func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
774 buf := make([]byte, 4096)
775 defer func() {
776 // Clean up when done
777 s.ptyMutex.Lock()
778 delete(s.terminalSessions, sessionID)
779 s.ptyMutex.Unlock()
780
781 // Close the PTY
782 session.pty.Close()
783
784 // Ensure process is terminated
785 if session.cmd.Process != nil {
786 session.cmd.Process.Signal(syscall.SIGTERM)
787 time.Sleep(100 * time.Millisecond)
788 session.cmd.Process.Kill()
789 }
790
791 // Close all client channels
792 session.eventsClientsMutex.Lock()
793 for ch := range session.eventsClients {
794 delete(session.eventsClients, ch)
795 close(ch)
796 }
797 session.eventsClientsMutex.Unlock()
798 }()
799
800 for {
801 n, err := session.pty.Read(buf)
802 if err != nil {
803 if err != io.EOF {
804 slog.Error("Failed to read from pty", "error", err)
805 }
806 break
807 }
808
809 // Make a copy of the data for each client
810 data := make([]byte, n)
811 copy(data, buf[:n])
812
813 // Broadcast to all connected clients
814 session.eventsClientsMutex.Lock()
815 for ch := range session.eventsClients {
816 // Try to send, but don't block if channel is full
817 select {
818 case ch <- data:
819 default:
820 // Channel is full, drop the message for this client
821 }
822 }
823 session.eventsClientsMutex.Unlock()
824 }
825}
826
827// getShellPath returns the path to the shell to use
828func getShellPath() string {
829 // Try to use the user's preferred shell
830 shell := os.Getenv("SHELL")
831 if shell != "" {
832 return shell
833 }
834
835 // Default to bash on Unix-like systems
836 if _, err := os.Stat("/bin/bash"); err == nil {
837 return "/bin/bash"
838 }
839
840 // Fall back to sh
841 return "/bin/sh"
842}
843
844func initDebugMux() *http.ServeMux {
845 mux := http.NewServeMux()
846 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
847 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700848 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 fmt.Fprintf(w, `<!doctype html>
850 <html><head><title>sketch debug</title></head><body>
851 <h1>sketch debug</h1>
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700852 pid %d
Earl Lee2e463fb2025-04-17 11:22:22 -0700853 <ul>
854 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
855 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
856 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
857 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
858 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
859 <li><a href="/debug/metrics">metrics</a></li>
860 </ul>
861 </body>
862 </html>
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700863 `, os.Getpid())
Earl Lee2e463fb2025-04-17 11:22:22 -0700864 })
865 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
866 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
867 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
868 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
869 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
870 return mux
871}
872
873// isValidGitSHA validates if a string looks like a valid git SHA hash.
874// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
875func isValidGitSHA(sha string) bool {
876 // Git SHA must be a hexadecimal string with at least 4 characters
877 if len(sha) < 4 || len(sha) > 40 {
878 return false
879 }
880
881 // Check if the string only contains hexadecimal characters
882 for _, char := range sha {
883 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
884 return false
885 }
886 }
887
888 return true
889}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000890
891// /stream?from=N endpoint for Server-Sent Events
892func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
893 w.Header().Set("Content-Type", "text/event-stream")
894 w.Header().Set("Cache-Control", "no-cache")
895 w.Header().Set("Connection", "keep-alive")
896 w.Header().Set("Access-Control-Allow-Origin", "*")
897
898 // Extract the 'from' parameter
899 fromParam := r.URL.Query().Get("from")
900 var fromIndex int
901 var err error
902 if fromParam != "" {
903 fromIndex, err = strconv.Atoi(fromParam)
904 if err != nil {
905 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
906 return
907 }
908 }
909
910 // Ensure 'from' is valid
911 currentCount := s.agent.MessageCount()
912 if fromIndex < 0 {
913 fromIndex = 0
914 } else if fromIndex > currentCount {
915 fromIndex = currentCount
916 }
917
918 // Send the current state immediately
919 state := s.getState()
920
921 // Create JSON encoder
922 encoder := json.NewEncoder(w)
923
924 // Send state as an event
925 fmt.Fprintf(w, "event: state\n")
926 fmt.Fprintf(w, "data: ")
927 encoder.Encode(state)
928 fmt.Fprintf(w, "\n\n")
929
930 if f, ok := w.(http.Flusher); ok {
931 f.Flush()
932 }
933
934 // Create a context for the SSE stream
935 ctx := r.Context()
936
937 // Create an iterator to receive new messages as they arrive
938 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
939 defer iterator.Close()
940
941 // Setup heartbeat timer
942 heartbeatTicker := time.NewTicker(45 * time.Second)
943 defer heartbeatTicker.Stop()
944
945 // Create a channel for messages
946 messageChan := make(chan *loop.AgentMessage, 10)
947
948 // Start a goroutine to read messages without blocking the heartbeat
949 go func() {
950 defer close(messageChan)
951 for {
952 // This can block, but it's in its own goroutine
953 newMessage := iterator.Next()
954 if newMessage == nil {
955 // No message available (likely due to context cancellation)
956 slog.InfoContext(ctx, "No more messages available, ending message stream")
957 return
958 }
959
960 select {
961 case messageChan <- newMessage:
962 // Message sent to channel
963 case <-ctx.Done():
964 // Context cancelled
965 return
966 }
967 }
968 }()
969
970 // Stay connected and stream real-time updates
971 for {
972 select {
973 case <-heartbeatTicker.C:
974 // Send heartbeat event
975 fmt.Fprintf(w, "event: heartbeat\n")
976 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
977
978 // Flush to send the heartbeat immediately
979 if f, ok := w.(http.Flusher); ok {
980 f.Flush()
981 }
982
983 case <-ctx.Done():
984 // Client disconnected
985 slog.InfoContext(ctx, "Client disconnected from SSE stream")
986 return
987
988 case newMessage, ok := <-messageChan:
989 if !ok {
990 // Channel closed
991 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
992 return
993 }
994
995 // Send the new message as an event
996 fmt.Fprintf(w, "event: message\n")
997 fmt.Fprintf(w, "data: ")
998 encoder.Encode(newMessage)
999 fmt.Fprintf(w, "\n\n")
1000
1001 // Get updated state
1002 state = s.getState()
1003
1004 // Send updated state after the message
1005 fmt.Fprintf(w, "event: state\n")
1006 fmt.Fprintf(w, "data: ")
1007 encoder.Encode(state)
1008 fmt.Fprintf(w, "\n\n")
1009
1010 // Flush to send the message and state immediately
1011 if f, ok := w.(http.Flusher); ok {
1012 f.Flush()
1013 }
1014 }
1015 }
1016}
1017
1018// Helper function to get the current state
1019func (s *Server) getState() State {
1020 serverMessageCount := s.agent.MessageCount()
1021 totalUsage := s.agent.TotalUsage()
1022
1023 return State{
1024 MessageCount: serverMessageCount,
1025 TotalUsage: &totalUsage,
1026 Hostname: s.hostname,
1027 WorkingDir: getWorkingDir(),
1028 InitialCommit: s.agent.InitialCommit(),
1029 Title: s.agent.Title(),
1030 BranchName: s.agent.BranchName(),
1031 OS: s.agent.OS(),
1032 OutsideHostname: s.agent.OutsideHostname(),
1033 InsideHostname: s.hostname,
1034 OutsideOS: s.agent.OutsideOS(),
1035 InsideOS: s.agent.OS(),
1036 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1037 InsideWorkingDir: getWorkingDir(),
1038 GitOrigin: s.agent.GitOrigin(),
1039 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1040 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1041 SessionID: s.agent.SessionID(),
1042 SSHAvailable: s.sshAvailable,
1043 SSHError: s.sshError,
1044 InContainer: s.agent.IsInContainer(),
1045 FirstMessageIndex: s.agent.FirstMessageIndex(),
1046 AgentState: s.agent.CurrentStateName(),
1047 }
1048}