blob: 3897fb9c1153c5a439cfd68367b5aa06bd13178d [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 {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -070088 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
89 HostAddr string `json:"host_addr"`
90
91 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +000092 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{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700218 InDocker: true,
219 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700220 }
221 if err := agent.Init(ini); err != nil {
222 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
223 return
224 }
225 w.Header().Set("Content-Type", "application/json")
226 io.WriteString(w, "{}\n")
227 })
228
229 // Handler for /messages?start=N&end=M (start/end are optional)
230 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
231 w.Header().Set("Content-Type", "application/json")
232
233 // Extract query parameters for range
234 var start, end int
235 var err error
236
237 currentCount := agent.MessageCount()
238
239 startParam := r.URL.Query().Get("start")
240 if startParam != "" {
241 start, err = strconv.Atoi(startParam)
242 if err != nil {
243 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
244 return
245 }
246 }
247
248 endParam := r.URL.Query().Get("end")
249 if endParam != "" {
250 end, err = strconv.Atoi(endParam)
251 if err != nil {
252 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
253 return
254 }
255 } else {
256 end = currentCount
257 }
258
259 if start < 0 || start > end || end > currentCount {
260 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
261 return
262 }
263
264 start = max(0, start)
265 end = min(agent.MessageCount(), end)
266 messages := agent.Messages(start, end)
267
268 // Create a JSON encoder with indentation for pretty-printing
269 encoder := json.NewEncoder(w)
270 encoder.SetIndent("", " ") // Two spaces for each indentation level
271
272 err = encoder.Encode(messages)
273 if err != nil {
274 http.Error(w, err.Error(), http.StatusInternalServerError)
275 }
276 })
277
278 // Handler for /logs - displays the contents of the log file
279 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
280 if s.logFile == nil {
281 http.Error(w, "log file not set", http.StatusNotFound)
282 return
283 }
284 logContents, err := os.ReadFile(s.logFile.Name())
285 if err != nil {
286 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
287 return
288 }
289 w.Header().Set("Content-Type", "text/html; charset=utf-8")
290 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
291 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
292 fmt.Fprintf(w, "</body>\n</html>")
293 })
294
295 // Handler for /download - downloads both messages and status as a JSON file
296 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
297 // Set headers for file download
298 w.Header().Set("Content-Type", "application/octet-stream")
299
300 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
301 timestamp := time.Now().Format("20060102-150405")
302 filename := fmt.Sprintf("sketch-%s.json", timestamp)
303
304 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
305
306 // Get all messages
307 messageCount := agent.MessageCount()
308 messages := agent.Messages(0, messageCount)
309
310 // Get status information (usage and other metadata)
311 totalUsage := agent.TotalUsage()
312 hostname := getHostname()
313 workingDir := getWorkingDir()
314
315 // Create a combined structure with all information
316 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 Messages []loop.AgentMessage `json:"messages"`
318 MessageCount int `json:"message_count"`
319 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
320 Hostname string `json:"hostname"`
321 WorkingDir string `json:"working_dir"`
322 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700323 }{
324 Messages: messages,
325 MessageCount: messageCount,
326 TotalUsage: totalUsage,
327 Hostname: hostname,
328 WorkingDir: workingDir,
329 DownloadTime: time.Now().Format(time.RFC3339),
330 }
331
332 // Marshal the JSON with indentation for better readability
333 jsonData, err := json.MarshalIndent(downloadData, "", " ")
334 if err != nil {
335 http.Error(w, err.Error(), http.StatusInternalServerError)
336 return
337 }
338 w.Write(jsonData)
339 })
340
341 // The latter doesn't return until the number of messages has changed (from seen
342 // or from when this was called.)
343 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
344 pollParam := r.URL.Query().Get("poll")
345 seenParam := r.URL.Query().Get("seen")
346
347 // Get the client's current message count (if provided)
348 clientMessageCount := -1
349 var err error
350 if seenParam != "" {
351 clientMessageCount, err = strconv.Atoi(seenParam)
352 if err != nil {
353 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
354 return
355 }
356 }
357
358 serverMessageCount := agent.MessageCount()
359
360 // Let lazy clients not have to specify this.
361 if clientMessageCount == -1 {
362 clientMessageCount = serverMessageCount
363 }
364
365 if pollParam == "true" {
366 ch := make(chan string)
367 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700368 it := agent.NewIterator(r.Context(), clientMessageCount)
369 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700370 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700371 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700372 }()
373 select {
374 case <-r.Context().Done():
375 slog.DebugContext(r.Context(), "abandoned poll request")
376 return
377 case <-time.After(90 * time.Second):
378 // Let the user call /state again to get the latest to limit how long our long polls hang out.
379 slog.DebugContext(r.Context(), "longish poll request")
380 break
381 case <-ch:
382 break
383 }
384 }
385
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 w.Header().Set("Content-Type", "application/json")
387
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000388 // Use the shared getState function
389 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700390
391 // Create a JSON encoder with indentation for pretty-printing
392 encoder := json.NewEncoder(w)
393 encoder.SetIndent("", " ") // Two spaces for each indentation level
394
395 err = encoder.Encode(state)
396 if err != nil {
397 http.Error(w, err.Error(), http.StatusInternalServerError)
398 }
399 })
400
Philip Zeyliger176de792025-04-21 12:25:18 -0700401 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700402
403 // Terminal WebSocket handler
404 // Terminal endpoints - predefined terminals 1-9
405 // TODO: The UI doesn't actually know how to use terminals 2-9!
406 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
407 if r.Method != http.MethodGet {
408 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
409 return
410 }
411 pathParts := strings.Split(r.URL.Path, "/")
412 if len(pathParts) < 4 {
413 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
414 return
415 }
416
417 sessionID := pathParts[3]
418 // Validate that the terminal ID is between 1-9
419 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
420 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
421 return
422 }
423
424 s.handleTerminalEvents(w, r, sessionID)
425 })
426
427 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
428 if r.Method != http.MethodPost {
429 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
430 return
431 }
432 pathParts := strings.Split(r.URL.Path, "/")
433 if len(pathParts) < 4 {
434 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
435 return
436 }
437 sessionID := pathParts[3]
438 s.handleTerminalInput(w, r, sessionID)
439 })
440
441 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700442 // Serve the sketch-app-shell.html file directly from the embedded filesystem
443 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700444 if err != nil {
445 http.Error(w, "File not found", http.StatusNotFound)
446 return
447 }
448 w.Header().Set("Content-Type", "text/html")
449 w.Write(data)
450 })
451
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700452 // Handler for POST /restart - restarts the conversation
453 s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
454 if r.Method != http.MethodPost {
455 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
456 return
457 }
458
459 // Parse the request body
460 var requestBody struct {
461 Revision string `json:"revision"`
462 InitialPrompt string `json:"initial_prompt"`
463 }
464
465 decoder := json.NewDecoder(r.Body)
466 if err := decoder.Decode(&requestBody); err != nil {
467 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
468 return
469 }
470 defer r.Body.Close()
471
472 // Call the restart method
473 err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
474 if err != nil {
475 http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
476 return
477 }
478
479 // Return success response
480 w.Header().Set("Content-Type", "application/json")
481 json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
482 })
483
484 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
485 // Handler for /commit-description - returns the description of a git commit
486 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
487 if r.Method != http.MethodGet {
488 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
489 return
490 }
491
492 // Get the revision parameter
493 revision := r.URL.Query().Get("revision")
494 if revision == "" {
495 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
496 return
497 }
498
499 // Run git command to get commit description
500 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
501 // Use the working directory from the agent
502 cmd.Dir = s.agent.WorkingDir()
503
504 output, err := cmd.CombinedOutput()
505 if err != nil {
506 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
507 return
508 }
509
510 // Prepare the response
511 resp := map[string]string{
512 "description": strings.TrimSpace(string(output)),
513 }
514
515 w.Header().Set("Content-Type", "application/json")
516 if err := json.NewEncoder(w).Encode(resp); err != nil {
517 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
518 }
519 })
520
521 // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
522 s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
523 if r.Method != http.MethodGet {
524 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
525 return
526 }
527
528 // Call the suggest reprompt method
529 suggestedPrompt, err := agent.SuggestReprompt(r.Context())
530 if err != nil {
531 http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
532 return
533 }
534
535 // Return success response
536 w.Header().Set("Content-Type", "application/json")
537 json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
538 })
539
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000540 // Handler for /screenshot/{id} - serves screenshot images
541 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
542 if r.Method != http.MethodGet {
543 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
544 return
545 }
546
547 // Extract the screenshot ID from the path
548 pathParts := strings.Split(r.URL.Path, "/")
549 if len(pathParts) < 3 {
550 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
551 return
552 }
553
554 screenshotID := pathParts[2]
555
556 // Validate the ID format (prevent directory traversal)
557 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
558 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
559 return
560 }
561
562 // Get the screenshot file path
563 filePath := browse.GetScreenshotPath(screenshotID)
564
565 // Check if the file exists
566 if _, err := os.Stat(filePath); os.IsNotExist(err) {
567 http.Error(w, "Screenshot not found", http.StatusNotFound)
568 return
569 }
570
571 // Serve the file
572 w.Header().Set("Content-Type", "image/png")
573 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
574 http.ServeFile(w, r, filePath)
575 })
576
Earl Lee2e463fb2025-04-17 11:22:22 -0700577 // Handler for POST /chat
578 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
579 if r.Method != http.MethodPost {
580 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
581 return
582 }
583
584 // Parse the request body
585 var requestBody struct {
586 Message string `json:"message"`
587 }
588
589 decoder := json.NewDecoder(r.Body)
590 if err := decoder.Decode(&requestBody); err != nil {
591 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
592 return
593 }
594 defer r.Body.Close()
595
596 if requestBody.Message == "" {
597 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
598 return
599 }
600
601 agent.UserMessage(r.Context(), requestBody.Message)
602
603 w.WriteHeader(http.StatusOK)
604 })
605
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000606 // Handler for POST /upload - uploads a file to /tmp
607 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
608 if r.Method != http.MethodPost {
609 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
610 return
611 }
612
613 // Limit to 10MB file size
614 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
615
616 // Parse the multipart form
617 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
618 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
619 return
620 }
621
622 // Get the file from the multipart form
623 file, handler, err := r.FormFile("file")
624 if err != nil {
625 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
626 return
627 }
628 defer file.Close()
629
630 // Generate a unique ID (8 random bytes converted to 16 hex chars)
631 randBytes := make([]byte, 8)
632 if _, err := rand.Read(randBytes); err != nil {
633 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
634 return
635 }
636
637 // Get file extension from the original filename
638 ext := filepath.Ext(handler.Filename)
639
640 // Create a unique filename in the /tmp directory
641 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
642
643 // Create the destination file
644 destFile, err := os.Create(filename)
645 if err != nil {
646 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
647 return
648 }
649 defer destFile.Close()
650
651 // Copy the file contents to the destination file
652 if _, err := io.Copy(destFile, file); err != nil {
653 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
654 return
655 }
656
657 // Return the path to the saved file
658 w.Header().Set("Content-Type", "application/json")
659 json.NewEncoder(w).Encode(map[string]string{"path": filename})
660 })
661
Earl Lee2e463fb2025-04-17 11:22:22 -0700662 // Handler for /cancel - cancels the current inner loop in progress
663 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
664 if r.Method != http.MethodPost {
665 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
666 return
667 }
668
669 // Parse the request body (optional)
670 var requestBody struct {
671 Reason string `json:"reason"`
672 ToolCallID string `json:"tool_call_id"`
673 }
674
675 decoder := json.NewDecoder(r.Body)
676 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
677 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
678 return
679 }
680 defer r.Body.Close()
681
682 cancelReason := "user requested cancellation"
683 if requestBody.Reason != "" {
684 cancelReason = requestBody.Reason
685 }
686
687 if requestBody.ToolCallID != "" {
688 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
689 if err != nil {
690 http.Error(w, err.Error(), http.StatusBadRequest)
691 return
692 }
693 // Return a success response
694 w.Header().Set("Content-Type", "application/json")
695 json.NewEncoder(w).Encode(map[string]string{
696 "status": "cancelled",
697 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700698 "reason": cancelReason,
699 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700700 return
701 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000702 // Call the CancelTurn method
703 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700704 // Return a success response
705 w.Header().Set("Content-Type", "application/json")
706 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
707 })
708
Pokey Rule397871d2025-05-19 15:02:45 +0100709 // Handler for /end - shuts down the inner sketch process
710 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
711 if r.Method != http.MethodPost {
712 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
713 return
714 }
715
716 // Parse the request body (optional)
717 var requestBody struct {
718 Reason string `json:"reason"`
719 }
720
721 decoder := json.NewDecoder(r.Body)
722 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
723 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
724 return
725 }
726 defer r.Body.Close()
727
728 endReason := "user requested end of session"
729 if requestBody.Reason != "" {
730 endReason = requestBody.Reason
731 }
732
733 // Send success response before exiting
734 w.Header().Set("Content-Type", "application/json")
735 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
736 if f, ok := w.(http.Flusher); ok {
737 f.Flush()
738 }
739
740 // Log that we're shutting down
741 slog.Info("Ending session", "reason", endReason)
742
743 // Exit the process after a short delay to allow response to be sent
744 go func() {
745 time.Sleep(100 * time.Millisecond)
746 os.Exit(0)
747 }()
748 })
749
Earl Lee2e463fb2025-04-17 11:22:22 -0700750 debugMux := initDebugMux()
751 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
752 debugMux.ServeHTTP(w, r)
753 })
754
755 return s, nil
756}
757
758// Utility functions
759func getHostname() string {
760 hostname, err := os.Hostname()
761 if err != nil {
762 return "unknown"
763 }
764 return hostname
765}
766
767func getWorkingDir() string {
768 wd, err := os.Getwd()
769 if err != nil {
770 return "unknown"
771 }
772 return wd
773}
774
775// createTerminalSession creates a new terminal session with the given ID
776func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
777 // Start a new shell process
778 shellPath := getShellPath()
779 cmd := exec.Command(shellPath)
780
781 // Get working directory from the agent if possible
782 workDir := getWorkingDir()
783 cmd.Dir = workDir
784
785 // Set up environment
786 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
787
788 // Start the command with a pty
789 ptmx, err := pty.Start(cmd)
790 if err != nil {
791 slog.Error("Failed to start pty", "error", err)
792 return nil, err
793 }
794
795 // Create the terminal session
796 session := &terminalSession{
797 pty: ptmx,
798 eventsClients: make(map[chan []byte]bool),
799 cmd: cmd,
800 }
801
802 // Start goroutine to read from pty and broadcast to all connected SSE clients
803 go s.readFromPtyAndBroadcast(sessionID, session)
804
805 return session, nil
806} // handleTerminalEvents handles SSE connections for terminal output
807func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
808 // Check if the session exists, if not, create it
809 s.ptyMutex.Lock()
810 session, exists := s.terminalSessions[sessionID]
811
812 if !exists {
813 // Create a new terminal session
814 var err error
815 session, err = s.createTerminalSession(sessionID)
816 if err != nil {
817 s.ptyMutex.Unlock()
818 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
819 return
820 }
821
822 // Store the new session
823 s.terminalSessions[sessionID] = session
824 }
825 s.ptyMutex.Unlock()
826
827 // Set headers for SSE
828 w.Header().Set("Content-Type", "text/event-stream")
829 w.Header().Set("Cache-Control", "no-cache")
830 w.Header().Set("Connection", "keep-alive")
831 w.Header().Set("Access-Control-Allow-Origin", "*")
832
833 // Create a channel for this client
834 events := make(chan []byte, 4096) // Buffer to prevent blocking
835
836 // Register this client's channel
837 session.eventsClientsMutex.Lock()
838 clientID := session.lastEventClientID + 1
839 session.lastEventClientID = clientID
840 session.eventsClients[events] = true
841 session.eventsClientsMutex.Unlock()
842
843 // When the client disconnects, remove their channel
844 defer func() {
845 session.eventsClientsMutex.Lock()
846 delete(session.eventsClients, events)
847 close(events)
848 session.eventsClientsMutex.Unlock()
849 }()
850
851 // Flush to send headers to client immediately
852 if f, ok := w.(http.Flusher); ok {
853 f.Flush()
854 }
855
856 // Send events to the client as they arrive
857 for {
858 select {
859 case <-r.Context().Done():
860 return
861 case data := <-events:
862 // Format as SSE with base64 encoding
863 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
864
865 // Flush the data immediately
866 if f, ok := w.(http.Flusher); ok {
867 f.Flush()
868 }
869 }
870 }
871}
872
873// handleTerminalInput processes input to the terminal
874func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
875 // Check if the session exists
876 s.ptyMutex.Lock()
877 session, exists := s.terminalSessions[sessionID]
878 s.ptyMutex.Unlock()
879
880 if !exists {
881 http.Error(w, "Terminal session not found", http.StatusNotFound)
882 return
883 }
884
885 // Read the request body (terminal input or resize command)
886 body, err := io.ReadAll(r.Body)
887 if err != nil {
888 http.Error(w, "Failed to read request body", http.StatusBadRequest)
889 return
890 }
891
892 // Check if it's a resize message
893 if len(body) > 0 && body[0] == '{' {
894 var msg TerminalMessage
895 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
896 if msg.Cols > 0 && msg.Rows > 0 {
897 pty.Setsize(session.pty, &pty.Winsize{
898 Cols: msg.Cols,
899 Rows: msg.Rows,
900 })
901
902 // Respond with success
903 w.WriteHeader(http.StatusOK)
904 return
905 }
906 }
907 }
908
909 // Regular terminal input
910 _, err = session.pty.Write(body)
911 if err != nil {
912 slog.Error("Failed to write to pty", "error", err)
913 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
914 return
915 }
916
917 // Respond with success
918 w.WriteHeader(http.StatusOK)
919}
920
921// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
922func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
923 buf := make([]byte, 4096)
924 defer func() {
925 // Clean up when done
926 s.ptyMutex.Lock()
927 delete(s.terminalSessions, sessionID)
928 s.ptyMutex.Unlock()
929
930 // Close the PTY
931 session.pty.Close()
932
933 // Ensure process is terminated
934 if session.cmd.Process != nil {
935 session.cmd.Process.Signal(syscall.SIGTERM)
936 time.Sleep(100 * time.Millisecond)
937 session.cmd.Process.Kill()
938 }
939
940 // Close all client channels
941 session.eventsClientsMutex.Lock()
942 for ch := range session.eventsClients {
943 delete(session.eventsClients, ch)
944 close(ch)
945 }
946 session.eventsClientsMutex.Unlock()
947 }()
948
949 for {
950 n, err := session.pty.Read(buf)
951 if err != nil {
952 if err != io.EOF {
953 slog.Error("Failed to read from pty", "error", err)
954 }
955 break
956 }
957
958 // Make a copy of the data for each client
959 data := make([]byte, n)
960 copy(data, buf[:n])
961
962 // Broadcast to all connected clients
963 session.eventsClientsMutex.Lock()
964 for ch := range session.eventsClients {
965 // Try to send, but don't block if channel is full
966 select {
967 case ch <- data:
968 default:
969 // Channel is full, drop the message for this client
970 }
971 }
972 session.eventsClientsMutex.Unlock()
973 }
974}
975
976// getShellPath returns the path to the shell to use
977func getShellPath() string {
978 // Try to use the user's preferred shell
979 shell := os.Getenv("SHELL")
980 if shell != "" {
981 return shell
982 }
983
984 // Default to bash on Unix-like systems
985 if _, err := os.Stat("/bin/bash"); err == nil {
986 return "/bin/bash"
987 }
988
989 // Fall back to sh
990 return "/bin/sh"
991}
992
993func initDebugMux() *http.ServeMux {
994 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -0700995 build := "unknown build"
996 bi, ok := debug.ReadBuildInfo()
997 if ok {
998 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
999 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001000 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1001 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001002 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001003 fmt.Fprintf(w, `<!doctype html>
1004 <html><head><title>sketch debug</title></head><body>
1005 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001006 pid %d<br>
1007 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001008 <ul>
1009 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
1010 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
1011 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
1012 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
1013 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1014 <li><a href="/debug/metrics">metrics</a></li>
1015 </ul>
1016 </body>
1017 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001018 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001019 })
1020 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1021 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1022 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1023 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1024 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1025 return mux
1026}
1027
1028// isValidGitSHA validates if a string looks like a valid git SHA hash.
1029// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1030func isValidGitSHA(sha string) bool {
1031 // Git SHA must be a hexadecimal string with at least 4 characters
1032 if len(sha) < 4 || len(sha) > 40 {
1033 return false
1034 }
1035
1036 // Check if the string only contains hexadecimal characters
1037 for _, char := range sha {
1038 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1039 return false
1040 }
1041 }
1042
1043 return true
1044}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001045
1046// /stream?from=N endpoint for Server-Sent Events
1047func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1048 w.Header().Set("Content-Type", "text/event-stream")
1049 w.Header().Set("Cache-Control", "no-cache")
1050 w.Header().Set("Connection", "keep-alive")
1051 w.Header().Set("Access-Control-Allow-Origin", "*")
1052
1053 // Extract the 'from' parameter
1054 fromParam := r.URL.Query().Get("from")
1055 var fromIndex int
1056 var err error
1057 if fromParam != "" {
1058 fromIndex, err = strconv.Atoi(fromParam)
1059 if err != nil {
1060 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1061 return
1062 }
1063 }
1064
1065 // Ensure 'from' is valid
1066 currentCount := s.agent.MessageCount()
1067 if fromIndex < 0 {
1068 fromIndex = 0
1069 } else if fromIndex > currentCount {
1070 fromIndex = currentCount
1071 }
1072
1073 // Send the current state immediately
1074 state := s.getState()
1075
1076 // Create JSON encoder
1077 encoder := json.NewEncoder(w)
1078
1079 // Send state as an event
1080 fmt.Fprintf(w, "event: state\n")
1081 fmt.Fprintf(w, "data: ")
1082 encoder.Encode(state)
1083 fmt.Fprintf(w, "\n\n")
1084
1085 if f, ok := w.(http.Flusher); ok {
1086 f.Flush()
1087 }
1088
1089 // Create a context for the SSE stream
1090 ctx := r.Context()
1091
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001092 // Setup heartbeat timer
1093 heartbeatTicker := time.NewTicker(45 * time.Second)
1094 defer heartbeatTicker.Stop()
1095
1096 // Create a channel for messages
1097 messageChan := make(chan *loop.AgentMessage, 10)
1098
Philip Zeyligereab12de2025-05-14 02:35:53 +00001099 // Create a channel for state transitions
1100 stateChan := make(chan *loop.StateTransition, 10)
1101
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001102 // Start a goroutine to read messages without blocking the heartbeat
1103 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001104 // Create an iterator to receive new messages as they arrive
1105 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1106 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001107 defer close(messageChan)
1108 for {
1109 // This can block, but it's in its own goroutine
1110 newMessage := iterator.Next()
1111 if newMessage == nil {
1112 // No message available (likely due to context cancellation)
1113 slog.InfoContext(ctx, "No more messages available, ending message stream")
1114 return
1115 }
1116
1117 select {
1118 case messageChan <- newMessage:
1119 // Message sent to channel
1120 case <-ctx.Done():
1121 // Context cancelled
1122 return
1123 }
1124 }
1125 }()
1126
Philip Zeyligereab12de2025-05-14 02:35:53 +00001127 // Start a goroutine to read state transitions
1128 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001129 // Create an iterator to receive state transitions
1130 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1131 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001132 defer close(stateChan)
1133 for {
1134 // This can block, but it's in its own goroutine
1135 newTransition := stateIterator.Next()
1136 if newTransition == nil {
1137 // No transition available (likely due to context cancellation)
1138 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1139 return
1140 }
1141
1142 select {
1143 case stateChan <- newTransition:
1144 // Transition sent to channel
1145 case <-ctx.Done():
1146 // Context cancelled
1147 return
1148 }
1149 }
1150 }()
1151
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001152 // Stay connected and stream real-time updates
1153 for {
1154 select {
1155 case <-heartbeatTicker.C:
1156 // Send heartbeat event
1157 fmt.Fprintf(w, "event: heartbeat\n")
1158 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1159
1160 // Flush to send the heartbeat immediately
1161 if f, ok := w.(http.Flusher); ok {
1162 f.Flush()
1163 }
1164
1165 case <-ctx.Done():
1166 // Client disconnected
1167 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1168 return
1169
Philip Zeyligereab12de2025-05-14 02:35:53 +00001170 case _, ok := <-stateChan:
1171 if !ok {
1172 // Channel closed
1173 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1174 return
1175 }
1176
1177 // Get updated state
1178 state = s.getState()
1179
1180 // Send updated state after the state transition
1181 fmt.Fprintf(w, "event: state\n")
1182 fmt.Fprintf(w, "data: ")
1183 encoder.Encode(state)
1184 fmt.Fprintf(w, "\n\n")
1185
1186 // Flush to send the state immediately
1187 if f, ok := w.(http.Flusher); ok {
1188 f.Flush()
1189 }
1190
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001191 case newMessage, ok := <-messageChan:
1192 if !ok {
1193 // Channel closed
1194 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1195 return
1196 }
1197
1198 // Send the new message as an event
1199 fmt.Fprintf(w, "event: message\n")
1200 fmt.Fprintf(w, "data: ")
1201 encoder.Encode(newMessage)
1202 fmt.Fprintf(w, "\n\n")
1203
1204 // Get updated state
1205 state = s.getState()
1206
1207 // Send updated state after the message
1208 fmt.Fprintf(w, "event: state\n")
1209 fmt.Fprintf(w, "data: ")
1210 encoder.Encode(state)
1211 fmt.Fprintf(w, "\n\n")
1212
1213 // Flush to send the message and state immediately
1214 if f, ok := w.(http.Flusher); ok {
1215 f.Flush()
1216 }
1217 }
1218 }
1219}
1220
1221// Helper function to get the current state
1222func (s *Server) getState() State {
1223 serverMessageCount := s.agent.MessageCount()
1224 totalUsage := s.agent.TotalUsage()
1225
1226 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001227 StateVersion: 2,
1228 MessageCount: serverMessageCount,
1229 TotalUsage: &totalUsage,
1230 Hostname: s.hostname,
1231 WorkingDir: getWorkingDir(),
1232 // TODO: Rename this field to sketch-base?
1233 InitialCommit: s.agent.SketchGitBase(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001234 Title: s.agent.Title(),
1235 BranchName: s.agent.BranchName(),
1236 OS: s.agent.OS(),
1237 OutsideHostname: s.agent.OutsideHostname(),
1238 InsideHostname: s.hostname,
1239 OutsideOS: s.agent.OutsideOS(),
1240 InsideOS: s.agent.OS(),
1241 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1242 InsideWorkingDir: getWorkingDir(),
1243 GitOrigin: s.agent.GitOrigin(),
1244 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1245 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1246 SessionID: s.agent.SessionID(),
1247 SSHAvailable: s.sshAvailable,
1248 SSHError: s.sshError,
1249 InContainer: s.agent.IsInContainer(),
1250 FirstMessageIndex: s.agent.FirstMessageIndex(),
1251 AgentState: s.agent.CurrentStateName(),
1252 }
1253}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001254
1255func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1256 if r.Method != "GET" {
1257 w.WriteHeader(http.StatusMethodNotAllowed)
1258 return
1259 }
1260
1261 // Get the git working directory from agent
1262 repoDir := s.agent.WorkingDir()
1263
1264 // Parse query parameters
1265 query := r.URL.Query()
1266 commit := query.Get("commit")
1267 from := query.Get("from")
1268 to := query.Get("to")
1269
1270 // If commit is specified, use commit^ and commit as from and to
1271 if commit != "" {
1272 from = commit + "^"
1273 to = commit
1274 }
1275
1276 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001277 if from == "" {
1278 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001279 return
1280 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001281 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001282
1283 // Call the git_tools function
1284 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1285 if err != nil {
1286 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1287 return
1288 }
1289
1290 // Return the result as JSON
1291 w.Header().Set("Content-Type", "application/json")
1292 if err := json.NewEncoder(w).Encode(diff); err != nil {
1293 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1294 return
1295 }
1296}
1297
1298func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1299 if r.Method != "GET" {
1300 w.WriteHeader(http.StatusMethodNotAllowed)
1301 return
1302 }
1303
1304 // Get the git working directory from agent
1305 repoDir := s.agent.WorkingDir()
1306
1307 // Parse query parameters
1308 hash := r.URL.Query().Get("hash")
1309 if hash == "" {
1310 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1311 return
1312 }
1313
1314 // Call the git_tools function
1315 show, err := git_tools.GitShow(repoDir, hash)
1316 if err != nil {
1317 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1318 return
1319 }
1320
1321 // Create a JSON response
1322 response := map[string]string{
1323 "hash": hash,
1324 "output": show,
1325 }
1326
1327 // Return the result as JSON
1328 w.Header().Set("Content-Type", "application/json")
1329 if err := json.NewEncoder(w).Encode(response); err != nil {
1330 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1331 return
1332 }
1333}
1334
1335func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1336 if r.Method != "GET" {
1337 w.WriteHeader(http.StatusMethodNotAllowed)
1338 return
1339 }
1340
1341 // Get the git working directory and initial commit from agent
1342 repoDir := s.agent.WorkingDir()
1343 initialCommit := s.agent.SketchGitBaseRef()
1344
1345 // Call the git_tools function
1346 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1347 if err != nil {
1348 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1349 return
1350 }
1351
1352 // Return the result as JSON
1353 w.Header().Set("Content-Type", "application/json")
1354 if err := json.NewEncoder(w).Encode(log); err != nil {
1355 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1356 return
1357 }
1358}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001359
1360func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1361 if r.Method != "GET" {
1362 w.WriteHeader(http.StatusMethodNotAllowed)
1363 return
1364 }
1365
1366 // Get the git working directory from agent
1367 repoDir := s.agent.WorkingDir()
1368
1369 // Parse query parameters
1370 query := r.URL.Query()
1371 path := query.Get("path")
1372
1373 // Check if path is provided
1374 if path == "" {
1375 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1376 return
1377 }
1378
1379 // Get file content using GitCat
1380 content, err := git_tools.GitCat(repoDir, path)
1381 if err != nil {
1382 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1383 return
1384 }
1385
1386 // Return the content as JSON for consistency with other endpoints
1387 w.Header().Set("Content-Type", "application/json")
1388 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1389 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1390 return
1391 }
1392}
1393
1394func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1395 if r.Method != "POST" {
1396 w.WriteHeader(http.StatusMethodNotAllowed)
1397 return
1398 }
1399
1400 // Get the git working directory from agent
1401 repoDir := s.agent.WorkingDir()
1402
1403 // Parse request body
1404 var requestBody struct {
1405 Path string `json:"path"`
1406 Content string `json:"content"`
1407 }
1408
1409 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1410 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1411 return
1412 }
1413 defer r.Body.Close()
1414
1415 // Check if path is provided
1416 if requestBody.Path == "" {
1417 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1418 return
1419 }
1420
1421 // Save file content using GitSaveFile
1422 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1423 if err != nil {
1424 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1425 return
1426 }
1427
1428 // Return simple success response
1429 w.WriteHeader(http.StatusOK)
1430 w.Write([]byte("ok"))
1431}