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