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