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