blob: 07127df662dd5d4da7921fe89009d5dc8324876f [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
Sean McCulloughbaa2b592025-04-23 10:40:08 -07005 "context"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00006 "crypto/rand"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/base64"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00008 "encoding/hex"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "encoding/json"
10 "fmt"
11 "html"
12 "io"
13 "io/fs"
14 "log/slog"
15 "net/http"
16 "net/http/pprof"
17 "os"
18 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000019 "path/filepath"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070020 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "strconv"
22 "strings"
23 "sync"
24 "syscall"
25 "time"
26
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000027 "sketch.dev/git_tools"
Philip Zeyliger176de792025-04-21 12:25:18 -070028 "sketch.dev/loop/server/gzhandler"
29
Earl Lee2e463fb2025-04-17 11:22:22 -070030 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000031 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033 "sketch.dev/loop"
Philip Zeyliger2032b1c2025-04-23 19:40:42 -070034 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070035)
36
37// terminalSession represents a terminal session with its PTY and the event channel
38type terminalSession struct {
39 pty *os.File
40 eventsClients map[chan []byte]bool
41 lastEventClientID int
42 eventsClientsMutex sync.Mutex
43 cmd *exec.Cmd
44}
45
46// TerminalMessage represents a message sent from the client for terminal resize events
47type TerminalMessage struct {
48 Type string `json:"type"`
49 Cols uint16 `json:"cols"`
50 Rows uint16 `json:"rows"`
51}
52
53// TerminalResponse represents the response for a new terminal creation
54type TerminalResponse struct {
55 SessionID string `json:"sessionId"`
56}
57
Sean McCulloughd9f13372025-04-21 15:08:49 -070058type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -070059 // null or 1: "old"
60 // 2: supports SSE for message updates
61 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070062 MessageCount int `json:"message_count"`
63 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
64 InitialCommit string `json:"initial_commit"`
65 Title string `json:"title"`
66 BranchName string `json:"branch_name,omitempty"`
67 Hostname string `json:"hostname"` // deprecated
68 WorkingDir string `json:"working_dir"` // deprecated
69 OS string `json:"os"` // deprecated
70 GitOrigin string `json:"git_origin,omitempty"`
71 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
72 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
73 SessionID string `json:"session_id"`
74 SSHAvailable bool `json:"ssh_available"`
75 SSHError string `json:"ssh_error,omitempty"`
76 InContainer bool `json:"in_container"`
77 FirstMessageIndex int `json:"first_message_index"`
78 AgentState string `json:"agent_state,omitempty"`
79 OutsideHostname string `json:"outside_hostname,omitempty"`
80 InsideHostname string `json:"inside_hostname,omitempty"`
81 OutsideOS string `json:"outside_os,omitempty"`
82 InsideOS string `json:"inside_os,omitempty"`
83 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
84 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Sean McCulloughd9f13372025-04-21 15:08:49 -070085}
86
Sean McCulloughbaa2b592025-04-23 10:40:08 -070087type InitRequest struct {
Sean McCullough7013e9e2025-05-14 02:03:58 +000088 HostAddr string `json:"host_addr"`
89 OutsideHTTP string `json:"outside_http"`
90 GitRemoteAddr string `json:"git_remote_addr"`
91 Commit string `json:"commit"`
92 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
93 SSHServerIdentity []byte `json:"ssh_server_identity"`
94 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
95 SSHHostCertificate []byte `json:"ssh_host_certificate"`
96 SSHAvailable bool `json:"ssh_available"`
97 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -070098}
99
Earl Lee2e463fb2025-04-17 11:22:22 -0700100// Server serves sketch HTTP. Server implements http.Handler.
101type Server struct {
102 mux *http.ServeMux
103 agent loop.CodingAgent
104 hostname string
105 logFile *os.File
106 // Mutex to protect terminalSessions
107 ptyMutex sync.Mutex
108 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000109 sshAvailable bool
110 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700111}
112
113func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114 s.mux.ServeHTTP(w, r)
115}
116
117// New creates a new HTTP server.
118func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
119 s := &Server{
120 mux: http.NewServeMux(),
121 agent: agent,
122 hostname: getHostname(),
123 logFile: logFile,
124 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000125 sshAvailable: false,
126 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700127 }
128
129 webBundle, err := webui.Build()
130 if err != nil {
131 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
132 }
133
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000134 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000135
136 // Git tool endpoints
137 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
138 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700139 s.mux.HandleFunc("/git/cat", s.handleGitCat)
140 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000141 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
142
Earl Lee2e463fb2025-04-17 11:22:22 -0700143 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
144 // Check if a specific commit hash was requested
145 commit := r.URL.Query().Get("commit")
146
147 // Get the diff, optionally for a specific commit
148 var diff string
149 var err error
150 if commit != "" {
151 // Validate the commit hash format
152 if !isValidGitSHA(commit) {
153 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
154 return
155 }
156
157 diff, err = agent.Diff(&commit)
158 } else {
159 diff, err = agent.Diff(nil)
160 }
161
162 if err != nil {
163 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
164 return
165 }
166
167 w.Header().Set("Content-Type", "text/plain")
168 w.Write([]byte(diff))
169 })
170
171 // Handler for initialization called by host sketch binary when inside docker.
172 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
173 defer func() {
174 if err := recover(); err != nil {
175 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
176
177 // Return an error response to the client
178 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
179 }
180 }()
181
182 if r.Method != "POST" {
183 http.Error(w, "POST required", http.StatusBadRequest)
184 return
185 }
186
187 body, err := io.ReadAll(r.Body)
188 r.Body.Close()
189 if err != nil {
190 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
191 return
192 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700193
194 m := &InitRequest{}
195 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700196 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
197 return
198 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700199
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000200 // Store SSH availability info
201 s.sshAvailable = m.SSHAvailable
202 s.sshError = m.SSHError
203
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700204 // Start the SSH server if the init request included ssh keys.
205 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
206 go func() {
207 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000208 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700209 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000210 // Update SSH error if server fails to start
211 s.sshAvailable = false
212 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700213 }
214 }()
215 }
216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 ini := loop.AgentInit{
218 WorkingDir: "/app",
219 InDocker: true,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700220 Commit: m.Commit,
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000221 OutsideHTTP: m.OutsideHTTP,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700222 GitRemoteAddr: m.GitRemoteAddr,
223 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 }
225 if err := agent.Init(ini); err != nil {
226 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
227 return
228 }
229 w.Header().Set("Content-Type", "application/json")
230 io.WriteString(w, "{}\n")
231 })
232
233 // Handler for /messages?start=N&end=M (start/end are optional)
234 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
235 w.Header().Set("Content-Type", "application/json")
236
237 // Extract query parameters for range
238 var start, end int
239 var err error
240
241 currentCount := agent.MessageCount()
242
243 startParam := r.URL.Query().Get("start")
244 if startParam != "" {
245 start, err = strconv.Atoi(startParam)
246 if err != nil {
247 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
248 return
249 }
250 }
251
252 endParam := r.URL.Query().Get("end")
253 if endParam != "" {
254 end, err = strconv.Atoi(endParam)
255 if err != nil {
256 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
257 return
258 }
259 } else {
260 end = currentCount
261 }
262
263 if start < 0 || start > end || end > currentCount {
264 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
265 return
266 }
267
268 start = max(0, start)
269 end = min(agent.MessageCount(), end)
270 messages := agent.Messages(start, end)
271
272 // Create a JSON encoder with indentation for pretty-printing
273 encoder := json.NewEncoder(w)
274 encoder.SetIndent("", " ") // Two spaces for each indentation level
275
276 err = encoder.Encode(messages)
277 if err != nil {
278 http.Error(w, err.Error(), http.StatusInternalServerError)
279 }
280 })
281
282 // Handler for /logs - displays the contents of the log file
283 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
284 if s.logFile == nil {
285 http.Error(w, "log file not set", http.StatusNotFound)
286 return
287 }
288 logContents, err := os.ReadFile(s.logFile.Name())
289 if err != nil {
290 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
291 return
292 }
293 w.Header().Set("Content-Type", "text/html; charset=utf-8")
294 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
295 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
296 fmt.Fprintf(w, "</body>\n</html>")
297 })
298
299 // Handler for /download - downloads both messages and status as a JSON file
300 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
301 // Set headers for file download
302 w.Header().Set("Content-Type", "application/octet-stream")
303
304 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
305 timestamp := time.Now().Format("20060102-150405")
306 filename := fmt.Sprintf("sketch-%s.json", timestamp)
307
308 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
309
310 // Get all messages
311 messageCount := agent.MessageCount()
312 messages := agent.Messages(0, messageCount)
313
314 // Get status information (usage and other metadata)
315 totalUsage := agent.TotalUsage()
316 hostname := getHostname()
317 workingDir := getWorkingDir()
318
319 // Create a combined structure with all information
320 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700321 Messages []loop.AgentMessage `json:"messages"`
322 MessageCount int `json:"message_count"`
323 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
324 Hostname string `json:"hostname"`
325 WorkingDir string `json:"working_dir"`
326 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700327 }{
328 Messages: messages,
329 MessageCount: messageCount,
330 TotalUsage: totalUsage,
331 Hostname: hostname,
332 WorkingDir: workingDir,
333 DownloadTime: time.Now().Format(time.RFC3339),
334 }
335
336 // Marshal the JSON with indentation for better readability
337 jsonData, err := json.MarshalIndent(downloadData, "", " ")
338 if err != nil {
339 http.Error(w, err.Error(), http.StatusInternalServerError)
340 return
341 }
342 w.Write(jsonData)
343 })
344
345 // The latter doesn't return until the number of messages has changed (from seen
346 // or from when this was called.)
347 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
348 pollParam := r.URL.Query().Get("poll")
349 seenParam := r.URL.Query().Get("seen")
350
351 // Get the client's current message count (if provided)
352 clientMessageCount := -1
353 var err error
354 if seenParam != "" {
355 clientMessageCount, err = strconv.Atoi(seenParam)
356 if err != nil {
357 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
358 return
359 }
360 }
361
362 serverMessageCount := agent.MessageCount()
363
364 // Let lazy clients not have to specify this.
365 if clientMessageCount == -1 {
366 clientMessageCount = serverMessageCount
367 }
368
369 if pollParam == "true" {
370 ch := make(chan string)
371 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700372 it := agent.NewIterator(r.Context(), clientMessageCount)
373 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700374 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700375 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700376 }()
377 select {
378 case <-r.Context().Done():
379 slog.DebugContext(r.Context(), "abandoned poll request")
380 return
381 case <-time.After(90 * time.Second):
382 // Let the user call /state again to get the latest to limit how long our long polls hang out.
383 slog.DebugContext(r.Context(), "longish poll request")
384 break
385 case <-ch:
386 break
387 }
388 }
389
Earl Lee2e463fb2025-04-17 11:22:22 -0700390 w.Header().Set("Content-Type", "application/json")
391
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000392 // Use the shared getState function
393 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700394
395 // Create a JSON encoder with indentation for pretty-printing
396 encoder := json.NewEncoder(w)
397 encoder.SetIndent("", " ") // Two spaces for each indentation level
398
399 err = encoder.Encode(state)
400 if err != nil {
401 http.Error(w, err.Error(), http.StatusInternalServerError)
402 }
403 })
404
Philip Zeyliger176de792025-04-21 12:25:18 -0700405 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700406
407 // Terminal WebSocket handler
408 // Terminal endpoints - predefined terminals 1-9
409 // TODO: The UI doesn't actually know how to use terminals 2-9!
410 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
411 if r.Method != http.MethodGet {
412 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
413 return
414 }
415 pathParts := strings.Split(r.URL.Path, "/")
416 if len(pathParts) < 4 {
417 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
418 return
419 }
420
421 sessionID := pathParts[3]
422 // Validate that the terminal ID is between 1-9
423 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
424 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
425 return
426 }
427
428 s.handleTerminalEvents(w, r, sessionID)
429 })
430
431 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
432 if r.Method != http.MethodPost {
433 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
434 return
435 }
436 pathParts := strings.Split(r.URL.Path, "/")
437 if len(pathParts) < 4 {
438 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
439 return
440 }
441 sessionID := pathParts[3]
442 s.handleTerminalInput(w, r, sessionID)
443 })
444
445 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700446 // Serve the sketch-app-shell.html file directly from the embedded filesystem
447 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700448 if err != nil {
449 http.Error(w, "File not found", http.StatusNotFound)
450 return
451 }
452 w.Header().Set("Content-Type", "text/html")
453 w.Write(data)
454 })
455
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700456 // Handler for POST /restart - restarts the conversation
457 s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
458 if r.Method != http.MethodPost {
459 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
460 return
461 }
462
463 // Parse the request body
464 var requestBody struct {
465 Revision string `json:"revision"`
466 InitialPrompt string `json:"initial_prompt"`
467 }
468
469 decoder := json.NewDecoder(r.Body)
470 if err := decoder.Decode(&requestBody); err != nil {
471 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
472 return
473 }
474 defer r.Body.Close()
475
476 // Call the restart method
477 err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
478 if err != nil {
479 http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
480 return
481 }
482
483 // Return success response
484 w.Header().Set("Content-Type", "application/json")
485 json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
486 })
487
488 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
489 // Handler for /commit-description - returns the description of a git commit
490 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
491 if r.Method != http.MethodGet {
492 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
493 return
494 }
495
496 // Get the revision parameter
497 revision := r.URL.Query().Get("revision")
498 if revision == "" {
499 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
500 return
501 }
502
503 // Run git command to get commit description
504 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
505 // Use the working directory from the agent
506 cmd.Dir = s.agent.WorkingDir()
507
508 output, err := cmd.CombinedOutput()
509 if err != nil {
510 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
511 return
512 }
513
514 // Prepare the response
515 resp := map[string]string{
516 "description": strings.TrimSpace(string(output)),
517 }
518
519 w.Header().Set("Content-Type", "application/json")
520 if err := json.NewEncoder(w).Encode(resp); err != nil {
521 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
522 }
523 })
524
525 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
526 s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
527 if r.Method != http.MethodGet {
528 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
529 return
530 }
531
532 // Call the suggest reprompt method
533 suggestedPrompt, err := agent.SuggestReprompt(r.Context())
534 if err != nil {
535 http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
536 return
537 }
538
539 // Return success response
540 w.Header().Set("Content-Type", "application/json")
541 json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
542 })
543
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000544 // Handler for /screenshot/{id} - serves screenshot images
545 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
546 if r.Method != http.MethodGet {
547 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
548 return
549 }
550
551 // Extract the screenshot ID from the path
552 pathParts := strings.Split(r.URL.Path, "/")
553 if len(pathParts) < 3 {
554 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
555 return
556 }
557
558 screenshotID := pathParts[2]
559
560 // Validate the ID format (prevent directory traversal)
561 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
562 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
563 return
564 }
565
566 // Get the screenshot file path
567 filePath := browse.GetScreenshotPath(screenshotID)
568
569 // Check if the file exists
570 if _, err := os.Stat(filePath); os.IsNotExist(err) {
571 http.Error(w, "Screenshot not found", http.StatusNotFound)
572 return
573 }
574
575 // Serve the file
576 w.Header().Set("Content-Type", "image/png")
577 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
578 http.ServeFile(w, r, filePath)
579 })
580
Earl Lee2e463fb2025-04-17 11:22:22 -0700581 // Handler for POST /chat
582 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
583 if r.Method != http.MethodPost {
584 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
585 return
586 }
587
588 // Parse the request body
589 var requestBody struct {
590 Message string `json:"message"`
591 }
592
593 decoder := json.NewDecoder(r.Body)
594 if err := decoder.Decode(&requestBody); err != nil {
595 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
596 return
597 }
598 defer r.Body.Close()
599
600 if requestBody.Message == "" {
601 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
602 return
603 }
604
605 agent.UserMessage(r.Context(), requestBody.Message)
606
607 w.WriteHeader(http.StatusOK)
608 })
609
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000610 // Handler for POST /upload - uploads a file to /tmp
611 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
612 if r.Method != http.MethodPost {
613 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
614 return
615 }
616
617 // Limit to 10MB file size
618 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
619
620 // Parse the multipart form
621 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
622 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
623 return
624 }
625
626 // Get the file from the multipart form
627 file, handler, err := r.FormFile("file")
628 if err != nil {
629 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
630 return
631 }
632 defer file.Close()
633
634 // Generate a unique ID (8 random bytes converted to 16 hex chars)
635 randBytes := make([]byte, 8)
636 if _, err := rand.Read(randBytes); err != nil {
637 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
638 return
639 }
640
641 // Get file extension from the original filename
642 ext := filepath.Ext(handler.Filename)
643
644 // Create a unique filename in the /tmp directory
645 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
646
647 // Create the destination file
648 destFile, err := os.Create(filename)
649 if err != nil {
650 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
651 return
652 }
653 defer destFile.Close()
654
655 // Copy the file contents to the destination file
656 if _, err := io.Copy(destFile, file); err != nil {
657 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
658 return
659 }
660
661 // Return the path to the saved file
662 w.Header().Set("Content-Type", "application/json")
663 json.NewEncoder(w).Encode(map[string]string{"path": filename})
664 })
665
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 // Handler for /cancel - cancels the current inner loop in progress
667 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
668 if r.Method != http.MethodPost {
669 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
670 return
671 }
672
673 // Parse the request body (optional)
674 var requestBody struct {
675 Reason string `json:"reason"`
676 ToolCallID string `json:"tool_call_id"`
677 }
678
679 decoder := json.NewDecoder(r.Body)
680 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
681 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
682 return
683 }
684 defer r.Body.Close()
685
686 cancelReason := "user requested cancellation"
687 if requestBody.Reason != "" {
688 cancelReason = requestBody.Reason
689 }
690
691 if requestBody.ToolCallID != "" {
692 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
693 if err != nil {
694 http.Error(w, err.Error(), http.StatusBadRequest)
695 return
696 }
697 // Return a success response
698 w.Header().Set("Content-Type", "application/json")
699 json.NewEncoder(w).Encode(map[string]string{
700 "status": "cancelled",
701 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700702 "reason": cancelReason,
703 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700704 return
705 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000706 // Call the CancelTurn method
707 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 // Return a success response
709 w.Header().Set("Content-Type", "application/json")
710 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
711 })
712
Pokey Rule397871d2025-05-19 15:02:45 +0100713 // Handler for /end - shuts down the inner sketch process
714 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
715 if r.Method != http.MethodPost {
716 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
717 return
718 }
719
720 // Parse the request body (optional)
721 var requestBody struct {
722 Reason string `json:"reason"`
723 }
724
725 decoder := json.NewDecoder(r.Body)
726 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
727 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
728 return
729 }
730 defer r.Body.Close()
731
732 endReason := "user requested end of session"
733 if requestBody.Reason != "" {
734 endReason = requestBody.Reason
735 }
736
737 // Send success response before exiting
738 w.Header().Set("Content-Type", "application/json")
739 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
740 if f, ok := w.(http.Flusher); ok {
741 f.Flush()
742 }
743
744 // Log that we're shutting down
745 slog.Info("Ending session", "reason", endReason)
746
747 // Exit the process after a short delay to allow response to be sent
748 go func() {
749 time.Sleep(100 * time.Millisecond)
750 os.Exit(0)
751 }()
752 })
753
Earl Lee2e463fb2025-04-17 11:22:22 -0700754 debugMux := initDebugMux()
755 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
756 debugMux.ServeHTTP(w, r)
757 })
758
759 return s, nil
760}
761
762// Utility functions
763func getHostname() string {
764 hostname, err := os.Hostname()
765 if err != nil {
766 return "unknown"
767 }
768 return hostname
769}
770
771func getWorkingDir() string {
772 wd, err := os.Getwd()
773 if err != nil {
774 return "unknown"
775 }
776 return wd
777}
778
779// createTerminalSession creates a new terminal session with the given ID
780func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
781 // Start a new shell process
782 shellPath := getShellPath()
783 cmd := exec.Command(shellPath)
784
785 // Get working directory from the agent if possible
786 workDir := getWorkingDir()
787 cmd.Dir = workDir
788
789 // Set up environment
790 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
791
792 // Start the command with a pty
793 ptmx, err := pty.Start(cmd)
794 if err != nil {
795 slog.Error("Failed to start pty", "error", err)
796 return nil, err
797 }
798
799 // Create the terminal session
800 session := &terminalSession{
801 pty: ptmx,
802 eventsClients: make(map[chan []byte]bool),
803 cmd: cmd,
804 }
805
806 // Start goroutine to read from pty and broadcast to all connected SSE clients
807 go s.readFromPtyAndBroadcast(sessionID, session)
808
809 return session, nil
810} // handleTerminalEvents handles SSE connections for terminal output
811func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
812 // Check if the session exists, if not, create it
813 s.ptyMutex.Lock()
814 session, exists := s.terminalSessions[sessionID]
815
816 if !exists {
817 // Create a new terminal session
818 var err error
819 session, err = s.createTerminalSession(sessionID)
820 if err != nil {
821 s.ptyMutex.Unlock()
822 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
823 return
824 }
825
826 // Store the new session
827 s.terminalSessions[sessionID] = session
828 }
829 s.ptyMutex.Unlock()
830
831 // Set headers for SSE
832 w.Header().Set("Content-Type", "text/event-stream")
833 w.Header().Set("Cache-Control", "no-cache")
834 w.Header().Set("Connection", "keep-alive")
835 w.Header().Set("Access-Control-Allow-Origin", "*")
836
837 // Create a channel for this client
838 events := make(chan []byte, 4096) // Buffer to prevent blocking
839
840 // Register this client's channel
841 session.eventsClientsMutex.Lock()
842 clientID := session.lastEventClientID + 1
843 session.lastEventClientID = clientID
844 session.eventsClients[events] = true
845 session.eventsClientsMutex.Unlock()
846
847 // When the client disconnects, remove their channel
848 defer func() {
849 session.eventsClientsMutex.Lock()
850 delete(session.eventsClients, events)
851 close(events)
852 session.eventsClientsMutex.Unlock()
853 }()
854
855 // Flush to send headers to client immediately
856 if f, ok := w.(http.Flusher); ok {
857 f.Flush()
858 }
859
860 // Send events to the client as they arrive
861 for {
862 select {
863 case <-r.Context().Done():
864 return
865 case data := <-events:
866 // Format as SSE with base64 encoding
867 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
868
869 // Flush the data immediately
870 if f, ok := w.(http.Flusher); ok {
871 f.Flush()
872 }
873 }
874 }
875}
876
877// handleTerminalInput processes input to the terminal
878func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
879 // Check if the session exists
880 s.ptyMutex.Lock()
881 session, exists := s.terminalSessions[sessionID]
882 s.ptyMutex.Unlock()
883
884 if !exists {
885 http.Error(w, "Terminal session not found", http.StatusNotFound)
886 return
887 }
888
889 // Read the request body (terminal input or resize command)
890 body, err := io.ReadAll(r.Body)
891 if err != nil {
892 http.Error(w, "Failed to read request body", http.StatusBadRequest)
893 return
894 }
895
896 // Check if it's a resize message
897 if len(body) > 0 && body[0] == '{' {
898 var msg TerminalMessage
899 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
900 if msg.Cols > 0 && msg.Rows > 0 {
901 pty.Setsize(session.pty, &pty.Winsize{
902 Cols: msg.Cols,
903 Rows: msg.Rows,
904 })
905
906 // Respond with success
907 w.WriteHeader(http.StatusOK)
908 return
909 }
910 }
911 }
912
913 // Regular terminal input
914 _, err = session.pty.Write(body)
915 if err != nil {
916 slog.Error("Failed to write to pty", "error", err)
917 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
918 return
919 }
920
921 // Respond with success
922 w.WriteHeader(http.StatusOK)
923}
924
925// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
926func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
927 buf := make([]byte, 4096)
928 defer func() {
929 // Clean up when done
930 s.ptyMutex.Lock()
931 delete(s.terminalSessions, sessionID)
932 s.ptyMutex.Unlock()
933
934 // Close the PTY
935 session.pty.Close()
936
937 // Ensure process is terminated
938 if session.cmd.Process != nil {
939 session.cmd.Process.Signal(syscall.SIGTERM)
940 time.Sleep(100 * time.Millisecond)
941 session.cmd.Process.Kill()
942 }
943
944 // Close all client channels
945 session.eventsClientsMutex.Lock()
946 for ch := range session.eventsClients {
947 delete(session.eventsClients, ch)
948 close(ch)
949 }
950 session.eventsClientsMutex.Unlock()
951 }()
952
953 for {
954 n, err := session.pty.Read(buf)
955 if err != nil {
956 if err != io.EOF {
957 slog.Error("Failed to read from pty", "error", err)
958 }
959 break
960 }
961
962 // Make a copy of the data for each client
963 data := make([]byte, n)
964 copy(data, buf[:n])
965
966 // Broadcast to all connected clients
967 session.eventsClientsMutex.Lock()
968 for ch := range session.eventsClients {
969 // Try to send, but don't block if channel is full
970 select {
971 case ch <- data:
972 default:
973 // Channel is full, drop the message for this client
974 }
975 }
976 session.eventsClientsMutex.Unlock()
977 }
978}
979
980// getShellPath returns the path to the shell to use
981func getShellPath() string {
982 // Try to use the user's preferred shell
983 shell := os.Getenv("SHELL")
984 if shell != "" {
985 return shell
986 }
987
988 // Default to bash on Unix-like systems
989 if _, err := os.Stat("/bin/bash"); err == nil {
990 return "/bin/bash"
991 }
992
993 // Fall back to sh
994 return "/bin/sh"
995}
996
997func initDebugMux() *http.ServeMux {
998 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -0700999 build := "unknown build"
1000 bi, ok := debug.ReadBuildInfo()
1001 if ok {
1002 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1003 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001004 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1005 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001006 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001007 fmt.Fprintf(w, `<!doctype html>
1008 <html><head><title>sketch debug</title></head><body>
1009 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001010 pid %d<br>
1011 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001012 <ul>
1013 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
1014 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
1015 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
1016 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
1017 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1018 <li><a href="/debug/metrics">metrics</a></li>
1019 </ul>
1020 </body>
1021 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001022 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001023 })
1024 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1025 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1026 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1027 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1028 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1029 return mux
1030}
1031
1032// isValidGitSHA validates if a string looks like a valid git SHA hash.
1033// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1034func isValidGitSHA(sha string) bool {
1035 // Git SHA must be a hexadecimal string with at least 4 characters
1036 if len(sha) < 4 || len(sha) > 40 {
1037 return false
1038 }
1039
1040 // Check if the string only contains hexadecimal characters
1041 for _, char := range sha {
1042 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1043 return false
1044 }
1045 }
1046
1047 return true
1048}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001049
1050// /stream?from=N endpoint for Server-Sent Events
1051func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1052 w.Header().Set("Content-Type", "text/event-stream")
1053 w.Header().Set("Cache-Control", "no-cache")
1054 w.Header().Set("Connection", "keep-alive")
1055 w.Header().Set("Access-Control-Allow-Origin", "*")
1056
1057 // Extract the 'from' parameter
1058 fromParam := r.URL.Query().Get("from")
1059 var fromIndex int
1060 var err error
1061 if fromParam != "" {
1062 fromIndex, err = strconv.Atoi(fromParam)
1063 if err != nil {
1064 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1065 return
1066 }
1067 }
1068
1069 // Ensure 'from' is valid
1070 currentCount := s.agent.MessageCount()
1071 if fromIndex < 0 {
1072 fromIndex = 0
1073 } else if fromIndex > currentCount {
1074 fromIndex = currentCount
1075 }
1076
1077 // Send the current state immediately
1078 state := s.getState()
1079
1080 // Create JSON encoder
1081 encoder := json.NewEncoder(w)
1082
1083 // Send state as an event
1084 fmt.Fprintf(w, "event: state\n")
1085 fmt.Fprintf(w, "data: ")
1086 encoder.Encode(state)
1087 fmt.Fprintf(w, "\n\n")
1088
1089 if f, ok := w.(http.Flusher); ok {
1090 f.Flush()
1091 }
1092
1093 // Create a context for the SSE stream
1094 ctx := r.Context()
1095
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001096 // Setup heartbeat timer
1097 heartbeatTicker := time.NewTicker(45 * time.Second)
1098 defer heartbeatTicker.Stop()
1099
1100 // Create a channel for messages
1101 messageChan := make(chan *loop.AgentMessage, 10)
1102
Philip Zeyligereab12de2025-05-14 02:35:53 +00001103 // Create a channel for state transitions
1104 stateChan := make(chan *loop.StateTransition, 10)
1105
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001106 // Start a goroutine to read messages without blocking the heartbeat
1107 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001108 // Create an iterator to receive new messages as they arrive
1109 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1110 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001111 defer close(messageChan)
1112 for {
1113 // This can block, but it's in its own goroutine
1114 newMessage := iterator.Next()
1115 if newMessage == nil {
1116 // No message available (likely due to context cancellation)
1117 slog.InfoContext(ctx, "No more messages available, ending message stream")
1118 return
1119 }
1120
1121 select {
1122 case messageChan <- newMessage:
1123 // Message sent to channel
1124 case <-ctx.Done():
1125 // Context cancelled
1126 return
1127 }
1128 }
1129 }()
1130
Philip Zeyligereab12de2025-05-14 02:35:53 +00001131 // Start a goroutine to read state transitions
1132 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001133 // Create an iterator to receive state transitions
1134 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1135 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001136 defer close(stateChan)
1137 for {
1138 // This can block, but it's in its own goroutine
1139 newTransition := stateIterator.Next()
1140 if newTransition == nil {
1141 // No transition available (likely due to context cancellation)
1142 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1143 return
1144 }
1145
1146 select {
1147 case stateChan <- newTransition:
1148 // Transition sent to channel
1149 case <-ctx.Done():
1150 // Context cancelled
1151 return
1152 }
1153 }
1154 }()
1155
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001156 // Stay connected and stream real-time updates
1157 for {
1158 select {
1159 case <-heartbeatTicker.C:
1160 // Send heartbeat event
1161 fmt.Fprintf(w, "event: heartbeat\n")
1162 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1163
1164 // Flush to send the heartbeat immediately
1165 if f, ok := w.(http.Flusher); ok {
1166 f.Flush()
1167 }
1168
1169 case <-ctx.Done():
1170 // Client disconnected
1171 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1172 return
1173
Philip Zeyligereab12de2025-05-14 02:35:53 +00001174 case _, ok := <-stateChan:
1175 if !ok {
1176 // Channel closed
1177 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1178 return
1179 }
1180
1181 // Get updated state
1182 state = s.getState()
1183
1184 // Send updated state after the state transition
1185 fmt.Fprintf(w, "event: state\n")
1186 fmt.Fprintf(w, "data: ")
1187 encoder.Encode(state)
1188 fmt.Fprintf(w, "\n\n")
1189
1190 // Flush to send the state immediately
1191 if f, ok := w.(http.Flusher); ok {
1192 f.Flush()
1193 }
1194
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001195 case newMessage, ok := <-messageChan:
1196 if !ok {
1197 // Channel closed
1198 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1199 return
1200 }
1201
1202 // Send the new message as an event
1203 fmt.Fprintf(w, "event: message\n")
1204 fmt.Fprintf(w, "data: ")
1205 encoder.Encode(newMessage)
1206 fmt.Fprintf(w, "\n\n")
1207
1208 // Get updated state
1209 state = s.getState()
1210
1211 // Send updated state after the message
1212 fmt.Fprintf(w, "event: state\n")
1213 fmt.Fprintf(w, "data: ")
1214 encoder.Encode(state)
1215 fmt.Fprintf(w, "\n\n")
1216
1217 // Flush to send the message and state immediately
1218 if f, ok := w.(http.Flusher); ok {
1219 f.Flush()
1220 }
1221 }
1222 }
1223}
1224
1225// Helper function to get the current state
1226func (s *Server) getState() State {
1227 serverMessageCount := s.agent.MessageCount()
1228 totalUsage := s.agent.TotalUsage()
1229
1230 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001231 StateVersion: 2,
1232 MessageCount: serverMessageCount,
1233 TotalUsage: &totalUsage,
1234 Hostname: s.hostname,
1235 WorkingDir: getWorkingDir(),
1236 // TODO: Rename this field to sketch-base?
1237 InitialCommit: s.agent.SketchGitBase(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001238 Title: s.agent.Title(),
1239 BranchName: s.agent.BranchName(),
1240 OS: s.agent.OS(),
1241 OutsideHostname: s.agent.OutsideHostname(),
1242 InsideHostname: s.hostname,
1243 OutsideOS: s.agent.OutsideOS(),
1244 InsideOS: s.agent.OS(),
1245 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1246 InsideWorkingDir: getWorkingDir(),
1247 GitOrigin: s.agent.GitOrigin(),
1248 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1249 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1250 SessionID: s.agent.SessionID(),
1251 SSHAvailable: s.sshAvailable,
1252 SSHError: s.sshError,
1253 InContainer: s.agent.IsInContainer(),
1254 FirstMessageIndex: s.agent.FirstMessageIndex(),
1255 AgentState: s.agent.CurrentStateName(),
1256 }
1257}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001258
1259func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1260 if r.Method != "GET" {
1261 w.WriteHeader(http.StatusMethodNotAllowed)
1262 return
1263 }
1264
1265 // Get the git working directory from agent
1266 repoDir := s.agent.WorkingDir()
1267
1268 // Parse query parameters
1269 query := r.URL.Query()
1270 commit := query.Get("commit")
1271 from := query.Get("from")
1272 to := query.Get("to")
1273
1274 // If commit is specified, use commit^ and commit as from and to
1275 if commit != "" {
1276 from = commit + "^"
1277 to = commit
1278 }
1279
1280 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001281 if from == "" {
1282 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001283 return
1284 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001285 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001286
1287 // Call the git_tools function
1288 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1289 if err != nil {
1290 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1291 return
1292 }
1293
1294 // Return the result as JSON
1295 w.Header().Set("Content-Type", "application/json")
1296 if err := json.NewEncoder(w).Encode(diff); err != nil {
1297 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1298 return
1299 }
1300}
1301
1302func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1303 if r.Method != "GET" {
1304 w.WriteHeader(http.StatusMethodNotAllowed)
1305 return
1306 }
1307
1308 // Get the git working directory from agent
1309 repoDir := s.agent.WorkingDir()
1310
1311 // Parse query parameters
1312 hash := r.URL.Query().Get("hash")
1313 if hash == "" {
1314 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1315 return
1316 }
1317
1318 // Call the git_tools function
1319 show, err := git_tools.GitShow(repoDir, hash)
1320 if err != nil {
1321 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1322 return
1323 }
1324
1325 // Create a JSON response
1326 response := map[string]string{
1327 "hash": hash,
1328 "output": show,
1329 }
1330
1331 // Return the result as JSON
1332 w.Header().Set("Content-Type", "application/json")
1333 if err := json.NewEncoder(w).Encode(response); err != nil {
1334 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1335 return
1336 }
1337}
1338
1339func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1340 if r.Method != "GET" {
1341 w.WriteHeader(http.StatusMethodNotAllowed)
1342 return
1343 }
1344
1345 // Get the git working directory and initial commit from agent
1346 repoDir := s.agent.WorkingDir()
1347 initialCommit := s.agent.SketchGitBaseRef()
1348
1349 // Call the git_tools function
1350 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1351 if err != nil {
1352 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1353 return
1354 }
1355
1356 // Return the result as JSON
1357 w.Header().Set("Content-Type", "application/json")
1358 if err := json.NewEncoder(w).Encode(log); err != nil {
1359 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1360 return
1361 }
1362}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001363
1364func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1365 if r.Method != "GET" {
1366 w.WriteHeader(http.StatusMethodNotAllowed)
1367 return
1368 }
1369
1370 // Get the git working directory from agent
1371 repoDir := s.agent.WorkingDir()
1372
1373 // Parse query parameters
1374 query := r.URL.Query()
1375 path := query.Get("path")
1376
1377 // Check if path is provided
1378 if path == "" {
1379 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1380 return
1381 }
1382
1383 // Get file content using GitCat
1384 content, err := git_tools.GitCat(repoDir, path)
1385 if err != nil {
1386 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1387 return
1388 }
1389
1390 // Return the content as JSON for consistency with other endpoints
1391 w.Header().Set("Content-Type", "application/json")
1392 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1393 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1394 return
1395 }
1396}
1397
1398func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1399 if r.Method != "POST" {
1400 w.WriteHeader(http.StatusMethodNotAllowed)
1401 return
1402 }
1403
1404 // Get the git working directory from agent
1405 repoDir := s.agent.WorkingDir()
1406
1407 // Parse request body
1408 var requestBody struct {
1409 Path string `json:"path"`
1410 Content string `json:"content"`
1411 }
1412
1413 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1414 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1415 return
1416 }
1417 defer r.Body.Close()
1418
1419 // Check if path is provided
1420 if requestBody.Path == "" {
1421 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1422 return
1423 }
1424
1425 // Save file content using GitSaveFile
1426 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1427 if err != nil {
1428 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1429 return
1430 }
1431
1432 // Return simple success response
1433 w.WriteHeader(http.StatusOK)
1434 w.Write([]byte("ok"))
1435}