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