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