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