blob: 0819459296831b959ed1a7d7efa2a01cfc1bdc31 [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
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070058// TodoItem represents a single todo item for task management
59type TodoItem struct {
60 ID string `json:"id"`
61 Task string `json:"task"`
62 Status string `json:"status"` // queued, in-progress, completed
63}
64
65// TodoList represents a collection of todo items
66type TodoList struct {
67 Items []TodoItem `json:"items"`
68}
69
Sean McCulloughd9f13372025-04-21 15:08:49 -070070type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -070071 // null or 1: "old"
72 // 2: supports SSE for message updates
73 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070074 MessageCount int `json:"message_count"`
75 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
76 InitialCommit string `json:"initial_commit"`
77 Title string `json:"title"`
78 BranchName string `json:"branch_name,omitempty"`
79 Hostname string `json:"hostname"` // deprecated
80 WorkingDir string `json:"working_dir"` // deprecated
81 OS string `json:"os"` // deprecated
82 GitOrigin string `json:"git_origin,omitempty"`
83 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
84 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
85 SessionID string `json:"session_id"`
86 SSHAvailable bool `json:"ssh_available"`
87 SSHError string `json:"ssh_error,omitempty"`
88 InContainer bool `json:"in_container"`
89 FirstMessageIndex int `json:"first_message_index"`
90 AgentState string `json:"agent_state,omitempty"`
91 OutsideHostname string `json:"outside_hostname,omitempty"`
92 InsideHostname string `json:"inside_hostname,omitempty"`
93 OutsideOS string `json:"outside_os,omitempty"`
94 InsideOS string `json:"inside_os,omitempty"`
95 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
96 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070097 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
Sean McCulloughd9f13372025-04-21 15:08:49 -070098}
99
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700100type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700101 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
102 HostAddr string `json:"host_addr"`
103
104 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000105 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
106 SSHServerIdentity []byte `json:"ssh_server_identity"`
107 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
108 SSHHostCertificate []byte `json:"ssh_host_certificate"`
109 SSHAvailable bool `json:"ssh_available"`
110 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700111}
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113// Server serves sketch HTTP. Server implements http.Handler.
114type Server struct {
115 mux *http.ServeMux
116 agent loop.CodingAgent
117 hostname string
118 logFile *os.File
119 // Mutex to protect terminalSessions
120 ptyMutex sync.Mutex
121 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000122 sshAvailable bool
123 sshError string
Philip Zeyligerb5739402025-06-02 07:04:34 -0700124 // WaitGroup for clients waiting for end
125 endWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -0700126}
127
128func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
129 s.mux.ServeHTTP(w, r)
130}
131
132// New creates a new HTTP server.
133func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
134 s := &Server{
135 mux: http.NewServeMux(),
136 agent: agent,
137 hostname: getHostname(),
138 logFile: logFile,
139 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000140 sshAvailable: false,
141 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700142 }
143
144 webBundle, err := webui.Build()
145 if err != nil {
146 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
147 }
148
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000149 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000150
151 // Git tool endpoints
152 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
153 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700154 s.mux.HandleFunc("/git/cat", s.handleGitCat)
155 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000156 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
157
Earl Lee2e463fb2025-04-17 11:22:22 -0700158 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
159 // Check if a specific commit hash was requested
160 commit := r.URL.Query().Get("commit")
161
162 // Get the diff, optionally for a specific commit
163 var diff string
164 var err error
165 if commit != "" {
166 // Validate the commit hash format
167 if !isValidGitSHA(commit) {
168 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
169 return
170 }
171
172 diff, err = agent.Diff(&commit)
173 } else {
174 diff, err = agent.Diff(nil)
175 }
176
177 if err != nil {
178 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
179 return
180 }
181
182 w.Header().Set("Content-Type", "text/plain")
183 w.Write([]byte(diff))
184 })
185
186 // Handler for initialization called by host sketch binary when inside docker.
187 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
188 defer func() {
189 if err := recover(); err != nil {
190 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
191
192 // Return an error response to the client
193 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
194 }
195 }()
196
197 if r.Method != "POST" {
198 http.Error(w, "POST required", http.StatusBadRequest)
199 return
200 }
201
202 body, err := io.ReadAll(r.Body)
203 r.Body.Close()
204 if err != nil {
205 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
206 return
207 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700208
209 m := &InitRequest{}
210 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700211 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
212 return
213 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700214
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000215 // Store SSH availability info
216 s.sshAvailable = m.SSHAvailable
217 s.sshError = m.SSHError
218
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700219 // Start the SSH server if the init request included ssh keys.
220 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
221 go func() {
222 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000223 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700224 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000225 // Update SSH error if server fails to start
226 s.sshAvailable = false
227 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700228 }
229 }()
230 }
231
Earl Lee2e463fb2025-04-17 11:22:22 -0700232 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700233 InDocker: true,
234 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700235 }
236 if err := agent.Init(ini); err != nil {
237 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
238 return
239 }
240 w.Header().Set("Content-Type", "application/json")
241 io.WriteString(w, "{}\n")
242 })
243
Sean McCullough138ec242025-06-02 22:42:06 +0000244 // Handler for /port-events - returns recent port change events
245 s.mux.HandleFunc("/port-events", func(w http.ResponseWriter, r *http.Request) {
246 if r.Method != http.MethodGet {
247 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
248 return
249 }
250
251 w.Header().Set("Content-Type", "application/json")
252
253 // Get the 'since' query parameter for filtering events
254 sinceParam := r.URL.Query().Get("since")
255 var events []loop.PortEvent
256
257 // Get port monitor from agent
258 portMonitor := agent.GetPortMonitor()
259 if portMonitor == nil {
260 // Return empty array if port monitor not available
261 events = []loop.PortEvent{}
262 } else if sinceParam != "" {
263 // Parse the since timestamp
264 sinceTime, err := time.Parse(time.RFC3339, sinceParam)
265 if err != nil {
266 http.Error(w, fmt.Sprintf("Invalid 'since' timestamp format: %v", err), http.StatusBadRequest)
267 return
268 }
269 events = portMonitor.GetRecentEvents(sinceTime)
270 } else {
271 // Return all recent events
272 events = portMonitor.GetAllRecentEvents()
273 }
274
275 // Encode and return the events
276 if err := json.NewEncoder(w).Encode(events); err != nil {
277 slog.ErrorContext(r.Context(), "Error encoding port events response", slog.Any("err", err))
278 http.Error(w, "Internal server error", http.StatusInternalServerError)
279 }
280 })
281
Earl Lee2e463fb2025-04-17 11:22:22 -0700282 // Handler for /messages?start=N&end=M (start/end are optional)
283 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
284 w.Header().Set("Content-Type", "application/json")
285
286 // Extract query parameters for range
287 var start, end int
288 var err error
289
290 currentCount := agent.MessageCount()
291
292 startParam := r.URL.Query().Get("start")
293 if startParam != "" {
294 start, err = strconv.Atoi(startParam)
295 if err != nil {
296 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
297 return
298 }
299 }
300
301 endParam := r.URL.Query().Get("end")
302 if endParam != "" {
303 end, err = strconv.Atoi(endParam)
304 if err != nil {
305 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
306 return
307 }
308 } else {
309 end = currentCount
310 }
311
312 if start < 0 || start > end || end > currentCount {
313 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
314 return
315 }
316
317 start = max(0, start)
318 end = min(agent.MessageCount(), end)
319 messages := agent.Messages(start, end)
320
321 // Create a JSON encoder with indentation for pretty-printing
322 encoder := json.NewEncoder(w)
323 encoder.SetIndent("", " ") // Two spaces for each indentation level
324
325 err = encoder.Encode(messages)
326 if err != nil {
327 http.Error(w, err.Error(), http.StatusInternalServerError)
328 }
329 })
330
331 // Handler for /logs - displays the contents of the log file
332 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
333 if s.logFile == nil {
334 http.Error(w, "log file not set", http.StatusNotFound)
335 return
336 }
337 logContents, err := os.ReadFile(s.logFile.Name())
338 if err != nil {
339 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
340 return
341 }
342 w.Header().Set("Content-Type", "text/html; charset=utf-8")
343 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
344 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
345 fmt.Fprintf(w, "</body>\n</html>")
346 })
347
348 // Handler for /download - downloads both messages and status as a JSON file
349 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
350 // Set headers for file download
351 w.Header().Set("Content-Type", "application/octet-stream")
352
353 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
354 timestamp := time.Now().Format("20060102-150405")
355 filename := fmt.Sprintf("sketch-%s.json", timestamp)
356
357 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
358
359 // Get all messages
360 messageCount := agent.MessageCount()
361 messages := agent.Messages(0, messageCount)
362
363 // Get status information (usage and other metadata)
364 totalUsage := agent.TotalUsage()
365 hostname := getHostname()
366 workingDir := getWorkingDir()
367
368 // Create a combined structure with all information
369 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700370 Messages []loop.AgentMessage `json:"messages"`
371 MessageCount int `json:"message_count"`
372 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
373 Hostname string `json:"hostname"`
374 WorkingDir string `json:"working_dir"`
375 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700376 }{
377 Messages: messages,
378 MessageCount: messageCount,
379 TotalUsage: totalUsage,
380 Hostname: hostname,
381 WorkingDir: workingDir,
382 DownloadTime: time.Now().Format(time.RFC3339),
383 }
384
385 // Marshal the JSON with indentation for better readability
386 jsonData, err := json.MarshalIndent(downloadData, "", " ")
387 if err != nil {
388 http.Error(w, err.Error(), http.StatusInternalServerError)
389 return
390 }
391 w.Write(jsonData)
392 })
393
394 // The latter doesn't return until the number of messages has changed (from seen
395 // or from when this was called.)
396 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
397 pollParam := r.URL.Query().Get("poll")
398 seenParam := r.URL.Query().Get("seen")
399
400 // Get the client's current message count (if provided)
401 clientMessageCount := -1
402 var err error
403 if seenParam != "" {
404 clientMessageCount, err = strconv.Atoi(seenParam)
405 if err != nil {
406 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
407 return
408 }
409 }
410
411 serverMessageCount := agent.MessageCount()
412
413 // Let lazy clients not have to specify this.
414 if clientMessageCount == -1 {
415 clientMessageCount = serverMessageCount
416 }
417
418 if pollParam == "true" {
419 ch := make(chan string)
420 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700421 it := agent.NewIterator(r.Context(), clientMessageCount)
422 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700423 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700424 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 }()
426 select {
427 case <-r.Context().Done():
428 slog.DebugContext(r.Context(), "abandoned poll request")
429 return
430 case <-time.After(90 * time.Second):
431 // Let the user call /state again to get the latest to limit how long our long polls hang out.
432 slog.DebugContext(r.Context(), "longish poll request")
433 break
434 case <-ch:
435 break
436 }
437 }
438
Earl Lee2e463fb2025-04-17 11:22:22 -0700439 w.Header().Set("Content-Type", "application/json")
440
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000441 // Use the shared getState function
442 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700443
444 // Create a JSON encoder with indentation for pretty-printing
445 encoder := json.NewEncoder(w)
446 encoder.SetIndent("", " ") // Two spaces for each indentation level
447
448 err = encoder.Encode(state)
449 if err != nil {
450 http.Error(w, err.Error(), http.StatusInternalServerError)
451 }
452 })
453
Philip Zeyliger176de792025-04-21 12:25:18 -0700454 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700455
456 // Terminal WebSocket handler
457 // Terminal endpoints - predefined terminals 1-9
458 // TODO: The UI doesn't actually know how to use terminals 2-9!
459 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
460 if r.Method != http.MethodGet {
461 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
462 return
463 }
464 pathParts := strings.Split(r.URL.Path, "/")
465 if len(pathParts) < 4 {
466 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
467 return
468 }
469
470 sessionID := pathParts[3]
471 // Validate that the terminal ID is between 1-9
472 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
473 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
474 return
475 }
476
477 s.handleTerminalEvents(w, r, sessionID)
478 })
479
480 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
481 if r.Method != http.MethodPost {
482 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
483 return
484 }
485 pathParts := strings.Split(r.URL.Path, "/")
486 if len(pathParts) < 4 {
487 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
488 return
489 }
490 sessionID := pathParts[3]
491 s.handleTerminalInput(w, r, sessionID)
492 })
493
494 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700495 // Serve the sketch-app-shell.html file directly from the embedded filesystem
496 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700497 if err != nil {
498 http.Error(w, "File not found", http.StatusNotFound)
499 return
500 }
501 w.Header().Set("Content-Type", "text/html")
502 w.Write(data)
503 })
504
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700505 // Handler for /commit-description - returns the description of a git commit
506 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
507 if r.Method != http.MethodGet {
508 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
509 return
510 }
511
512 // Get the revision parameter
513 revision := r.URL.Query().Get("revision")
514 if revision == "" {
515 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
516 return
517 }
518
519 // Run git command to get commit description
520 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
521 // Use the working directory from the agent
522 cmd.Dir = s.agent.WorkingDir()
523
524 output, err := cmd.CombinedOutput()
525 if err != nil {
526 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
527 return
528 }
529
530 // Prepare the response
531 resp := map[string]string{
532 "description": strings.TrimSpace(string(output)),
533 }
534
535 w.Header().Set("Content-Type", "application/json")
536 if err := json.NewEncoder(w).Encode(resp); err != nil {
537 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
538 }
539 })
540
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000541 // Handler for /screenshot/{id} - serves screenshot images
542 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
543 if r.Method != http.MethodGet {
544 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
545 return
546 }
547
548 // Extract the screenshot ID from the path
549 pathParts := strings.Split(r.URL.Path, "/")
550 if len(pathParts) < 3 {
551 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
552 return
553 }
554
555 screenshotID := pathParts[2]
556
557 // Validate the ID format (prevent directory traversal)
558 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
559 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
560 return
561 }
562
563 // Get the screenshot file path
564 filePath := browse.GetScreenshotPath(screenshotID)
565
566 // Check if the file exists
567 if _, err := os.Stat(filePath); os.IsNotExist(err) {
568 http.Error(w, "Screenshot not found", http.StatusNotFound)
569 return
570 }
571
572 // Serve the file
573 w.Header().Set("Content-Type", "image/png")
574 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
575 http.ServeFile(w, r, filePath)
576 })
577
Earl Lee2e463fb2025-04-17 11:22:22 -0700578 // Handler for POST /chat
579 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
580 if r.Method != http.MethodPost {
581 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
582 return
583 }
584
585 // Parse the request body
586 var requestBody struct {
587 Message string `json:"message"`
588 }
589
590 decoder := json.NewDecoder(r.Body)
591 if err := decoder.Decode(&requestBody); err != nil {
592 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
593 return
594 }
595 defer r.Body.Close()
596
597 if requestBody.Message == "" {
598 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
599 return
600 }
601
602 agent.UserMessage(r.Context(), requestBody.Message)
603
604 w.WriteHeader(http.StatusOK)
605 })
606
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000607 // Handler for POST /upload - uploads a file to /tmp
608 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
609 if r.Method != http.MethodPost {
610 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
611 return
612 }
613
614 // Limit to 10MB file size
615 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
616
617 // Parse the multipart form
618 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
619 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
620 return
621 }
622
623 // Get the file from the multipart form
624 file, handler, err := r.FormFile("file")
625 if err != nil {
626 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
627 return
628 }
629 defer file.Close()
630
631 // Generate a unique ID (8 random bytes converted to 16 hex chars)
632 randBytes := make([]byte, 8)
633 if _, err := rand.Read(randBytes); err != nil {
634 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
635 return
636 }
637
638 // Get file extension from the original filename
639 ext := filepath.Ext(handler.Filename)
640
641 // Create a unique filename in the /tmp directory
642 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
643
644 // Create the destination file
645 destFile, err := os.Create(filename)
646 if err != nil {
647 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
648 return
649 }
650 defer destFile.Close()
651
652 // Copy the file contents to the destination file
653 if _, err := io.Copy(destFile, file); err != nil {
654 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
655 return
656 }
657
658 // Return the path to the saved file
659 w.Header().Set("Content-Type", "application/json")
660 json.NewEncoder(w).Encode(map[string]string{"path": filename})
661 })
662
Earl Lee2e463fb2025-04-17 11:22:22 -0700663 // Handler for /cancel - cancels the current inner loop in progress
664 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
665 if r.Method != http.MethodPost {
666 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
667 return
668 }
669
670 // Parse the request body (optional)
671 var requestBody struct {
672 Reason string `json:"reason"`
673 ToolCallID string `json:"tool_call_id"`
674 }
675
676 decoder := json.NewDecoder(r.Body)
677 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
678 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
679 return
680 }
681 defer r.Body.Close()
682
683 cancelReason := "user requested cancellation"
684 if requestBody.Reason != "" {
685 cancelReason = requestBody.Reason
686 }
687
688 if requestBody.ToolCallID != "" {
689 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
690 if err != nil {
691 http.Error(w, err.Error(), http.StatusBadRequest)
692 return
693 }
694 // Return a success response
695 w.Header().Set("Content-Type", "application/json")
696 json.NewEncoder(w).Encode(map[string]string{
697 "status": "cancelled",
698 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700699 "reason": cancelReason,
700 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700701 return
702 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000703 // Call the CancelTurn method
704 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700705 // Return a success response
706 w.Header().Set("Content-Type", "application/json")
707 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
708 })
709
Pokey Rule397871d2025-05-19 15:02:45 +0100710 // Handler for /end - shuts down the inner sketch process
711 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
712 if r.Method != http.MethodPost {
713 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
714 return
715 }
716
717 // Parse the request body (optional)
718 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700719 Reason string `json:"reason"`
720 Happy *bool `json:"happy,omitempty"`
721 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100722 }
723
724 decoder := json.NewDecoder(r.Body)
725 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
726 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
727 return
728 }
729 defer r.Body.Close()
730
731 endReason := "user requested end of session"
732 if requestBody.Reason != "" {
733 endReason = requestBody.Reason
734 }
735
736 // Send success response before exiting
737 w.Header().Set("Content-Type", "application/json")
738 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
739 if f, ok := w.(http.Flusher); ok {
740 f.Flush()
741 }
742
743 // Log that we're shutting down
744 slog.Info("Ending session", "reason", endReason)
745
Philip Zeyligerb5739402025-06-02 07:04:34 -0700746 // Wait for skaband clients that are waiting for end (with timeout)
Pokey Rule397871d2025-05-19 15:02:45 +0100747 go func() {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700748 startTime := time.Now()
749 // Wait up to 2 seconds for waiting clients to receive the end message
750 done := make(chan struct{})
751 go func() {
752 s.endWaitGroup.Wait()
753 close(done)
754 }()
755
756 select {
757 case <-done:
758 slog.Info("All waiting clients notified of end")
759 case <-time.After(2 * time.Second):
760 slog.Info("Timeout waiting for clients, proceeding with shutdown")
761 }
762
763 // Ensure we've been running for at least 100ms to allow response to be sent
764 elapsed := time.Since(startTime)
765 if elapsed < 100*time.Millisecond {
766 time.Sleep(100*time.Millisecond - elapsed)
767 }
768
Pokey Rule397871d2025-05-19 15:02:45 +0100769 os.Exit(0)
770 }()
771 })
772
Earl Lee2e463fb2025-04-17 11:22:22 -0700773 debugMux := initDebugMux()
774 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
775 debugMux.ServeHTTP(w, r)
776 })
777
778 return s, nil
779}
780
781// Utility functions
782func getHostname() string {
783 hostname, err := os.Hostname()
784 if err != nil {
785 return "unknown"
786 }
787 return hostname
788}
789
790func getWorkingDir() string {
791 wd, err := os.Getwd()
792 if err != nil {
793 return "unknown"
794 }
795 return wd
796}
797
798// createTerminalSession creates a new terminal session with the given ID
799func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
800 // Start a new shell process
801 shellPath := getShellPath()
802 cmd := exec.Command(shellPath)
803
804 // Get working directory from the agent if possible
805 workDir := getWorkingDir()
806 cmd.Dir = workDir
807
808 // Set up environment
809 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
810
811 // Start the command with a pty
812 ptmx, err := pty.Start(cmd)
813 if err != nil {
814 slog.Error("Failed to start pty", "error", err)
815 return nil, err
816 }
817
818 // Create the terminal session
819 session := &terminalSession{
820 pty: ptmx,
821 eventsClients: make(map[chan []byte]bool),
822 cmd: cmd,
823 }
824
825 // Start goroutine to read from pty and broadcast to all connected SSE clients
826 go s.readFromPtyAndBroadcast(sessionID, session)
827
828 return session, nil
829} // handleTerminalEvents handles SSE connections for terminal output
830func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
831 // Check if the session exists, if not, create it
832 s.ptyMutex.Lock()
833 session, exists := s.terminalSessions[sessionID]
834
835 if !exists {
836 // Create a new terminal session
837 var err error
838 session, err = s.createTerminalSession(sessionID)
839 if err != nil {
840 s.ptyMutex.Unlock()
841 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
842 return
843 }
844
845 // Store the new session
846 s.terminalSessions[sessionID] = session
847 }
848 s.ptyMutex.Unlock()
849
850 // Set headers for SSE
851 w.Header().Set("Content-Type", "text/event-stream")
852 w.Header().Set("Cache-Control", "no-cache")
853 w.Header().Set("Connection", "keep-alive")
854 w.Header().Set("Access-Control-Allow-Origin", "*")
855
856 // Create a channel for this client
857 events := make(chan []byte, 4096) // Buffer to prevent blocking
858
859 // Register this client's channel
860 session.eventsClientsMutex.Lock()
861 clientID := session.lastEventClientID + 1
862 session.lastEventClientID = clientID
863 session.eventsClients[events] = true
864 session.eventsClientsMutex.Unlock()
865
866 // When the client disconnects, remove their channel
867 defer func() {
868 session.eventsClientsMutex.Lock()
869 delete(session.eventsClients, events)
870 close(events)
871 session.eventsClientsMutex.Unlock()
872 }()
873
874 // Flush to send headers to client immediately
875 if f, ok := w.(http.Flusher); ok {
876 f.Flush()
877 }
878
879 // Send events to the client as they arrive
880 for {
881 select {
882 case <-r.Context().Done():
883 return
884 case data := <-events:
885 // Format as SSE with base64 encoding
886 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
887
888 // Flush the data immediately
889 if f, ok := w.(http.Flusher); ok {
890 f.Flush()
891 }
892 }
893 }
894}
895
896// handleTerminalInput processes input to the terminal
897func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
898 // Check if the session exists
899 s.ptyMutex.Lock()
900 session, exists := s.terminalSessions[sessionID]
901 s.ptyMutex.Unlock()
902
903 if !exists {
904 http.Error(w, "Terminal session not found", http.StatusNotFound)
905 return
906 }
907
908 // Read the request body (terminal input or resize command)
909 body, err := io.ReadAll(r.Body)
910 if err != nil {
911 http.Error(w, "Failed to read request body", http.StatusBadRequest)
912 return
913 }
914
915 // Check if it's a resize message
916 if len(body) > 0 && body[0] == '{' {
917 var msg TerminalMessage
918 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
919 if msg.Cols > 0 && msg.Rows > 0 {
920 pty.Setsize(session.pty, &pty.Winsize{
921 Cols: msg.Cols,
922 Rows: msg.Rows,
923 })
924
925 // Respond with success
926 w.WriteHeader(http.StatusOK)
927 return
928 }
929 }
930 }
931
932 // Regular terminal input
933 _, err = session.pty.Write(body)
934 if err != nil {
935 slog.Error("Failed to write to pty", "error", err)
936 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
937 return
938 }
939
940 // Respond with success
941 w.WriteHeader(http.StatusOK)
942}
943
944// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
945func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
946 buf := make([]byte, 4096)
947 defer func() {
948 // Clean up when done
949 s.ptyMutex.Lock()
950 delete(s.terminalSessions, sessionID)
951 s.ptyMutex.Unlock()
952
953 // Close the PTY
954 session.pty.Close()
955
956 // Ensure process is terminated
957 if session.cmd.Process != nil {
958 session.cmd.Process.Signal(syscall.SIGTERM)
959 time.Sleep(100 * time.Millisecond)
960 session.cmd.Process.Kill()
961 }
962
963 // Close all client channels
964 session.eventsClientsMutex.Lock()
965 for ch := range session.eventsClients {
966 delete(session.eventsClients, ch)
967 close(ch)
968 }
969 session.eventsClientsMutex.Unlock()
970 }()
971
972 for {
973 n, err := session.pty.Read(buf)
974 if err != nil {
975 if err != io.EOF {
976 slog.Error("Failed to read from pty", "error", err)
977 }
978 break
979 }
980
981 // Make a copy of the data for each client
982 data := make([]byte, n)
983 copy(data, buf[:n])
984
985 // Broadcast to all connected clients
986 session.eventsClientsMutex.Lock()
987 for ch := range session.eventsClients {
988 // Try to send, but don't block if channel is full
989 select {
990 case ch <- data:
991 default:
992 // Channel is full, drop the message for this client
993 }
994 }
995 session.eventsClientsMutex.Unlock()
996 }
997}
998
999// getShellPath returns the path to the shell to use
1000func getShellPath() string {
1001 // Try to use the user's preferred shell
1002 shell := os.Getenv("SHELL")
1003 if shell != "" {
1004 return shell
1005 }
1006
1007 // Default to bash on Unix-like systems
1008 if _, err := os.Stat("/bin/bash"); err == nil {
1009 return "/bin/bash"
1010 }
1011
1012 // Fall back to sh
1013 return "/bin/sh"
1014}
1015
1016func initDebugMux() *http.ServeMux {
1017 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001018 build := "unknown build"
1019 bi, ok := debug.ReadBuildInfo()
1020 if ok {
1021 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1022 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001023 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1024 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001025 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001026 fmt.Fprintf(w, `<!doctype html>
1027 <html><head><title>sketch debug</title></head><body>
1028 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001029 pid %d<br>
1030 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001031 <ul>
1032 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
1033 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
1034 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
1035 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
1036 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1037 <li><a href="/debug/metrics">metrics</a></li>
1038 </ul>
1039 </body>
1040 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001041 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001042 })
1043 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1044 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1045 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1046 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1047 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1048 return mux
1049}
1050
1051// isValidGitSHA validates if a string looks like a valid git SHA hash.
1052// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1053func isValidGitSHA(sha string) bool {
1054 // Git SHA must be a hexadecimal string with at least 4 characters
1055 if len(sha) < 4 || len(sha) > 40 {
1056 return false
1057 }
1058
1059 // Check if the string only contains hexadecimal characters
1060 for _, char := range sha {
1061 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1062 return false
1063 }
1064 }
1065
1066 return true
1067}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001068
1069// /stream?from=N endpoint for Server-Sent Events
1070func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1071 w.Header().Set("Content-Type", "text/event-stream")
1072 w.Header().Set("Cache-Control", "no-cache")
1073 w.Header().Set("Connection", "keep-alive")
1074 w.Header().Set("Access-Control-Allow-Origin", "*")
1075
1076 // Extract the 'from' parameter
1077 fromParam := r.URL.Query().Get("from")
1078 var fromIndex int
1079 var err error
1080 if fromParam != "" {
1081 fromIndex, err = strconv.Atoi(fromParam)
1082 if err != nil {
1083 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1084 return
1085 }
1086 }
1087
Philip Zeyligerb5739402025-06-02 07:04:34 -07001088 // Check if this client is waiting for end
1089 waitForEnd := r.URL.Query().Get("wait_for_end") == "true"
1090 if waitForEnd {
1091 s.endWaitGroup.Add(1)
1092 defer func() {
1093 if waitForEnd {
1094 s.endWaitGroup.Done()
1095 }
1096 }()
1097 }
1098
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001099 // Ensure 'from' is valid
1100 currentCount := s.agent.MessageCount()
1101 if fromIndex < 0 {
1102 fromIndex = 0
1103 } else if fromIndex > currentCount {
1104 fromIndex = currentCount
1105 }
1106
1107 // Send the current state immediately
1108 state := s.getState()
1109
1110 // Create JSON encoder
1111 encoder := json.NewEncoder(w)
1112
1113 // Send state as an event
1114 fmt.Fprintf(w, "event: state\n")
1115 fmt.Fprintf(w, "data: ")
1116 encoder.Encode(state)
1117 fmt.Fprintf(w, "\n\n")
1118
1119 if f, ok := w.(http.Flusher); ok {
1120 f.Flush()
1121 }
1122
1123 // Create a context for the SSE stream
1124 ctx := r.Context()
1125
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001126 // Setup heartbeat timer
1127 heartbeatTicker := time.NewTicker(45 * time.Second)
1128 defer heartbeatTicker.Stop()
1129
1130 // Create a channel for messages
1131 messageChan := make(chan *loop.AgentMessage, 10)
1132
Philip Zeyligereab12de2025-05-14 02:35:53 +00001133 // Create a channel for state transitions
1134 stateChan := make(chan *loop.StateTransition, 10)
1135
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001136 // Start a goroutine to read messages without blocking the heartbeat
1137 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001138 // Create an iterator to receive new messages as they arrive
1139 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1140 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001141 defer close(messageChan)
1142 for {
1143 // This can block, but it's in its own goroutine
1144 newMessage := iterator.Next()
1145 if newMessage == nil {
1146 // No message available (likely due to context cancellation)
1147 slog.InfoContext(ctx, "No more messages available, ending message stream")
1148 return
1149 }
1150
1151 select {
1152 case messageChan <- newMessage:
1153 // Message sent to channel
1154 case <-ctx.Done():
1155 // Context cancelled
1156 return
1157 }
1158 }
1159 }()
1160
Philip Zeyligereab12de2025-05-14 02:35:53 +00001161 // Start a goroutine to read state transitions
1162 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001163 // Create an iterator to receive state transitions
1164 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1165 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001166 defer close(stateChan)
1167 for {
1168 // This can block, but it's in its own goroutine
1169 newTransition := stateIterator.Next()
1170 if newTransition == nil {
1171 // No transition available (likely due to context cancellation)
1172 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1173 return
1174 }
1175
1176 select {
1177 case stateChan <- newTransition:
1178 // Transition sent to channel
1179 case <-ctx.Done():
1180 // Context cancelled
1181 return
1182 }
1183 }
1184 }()
1185
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001186 // Stay connected and stream real-time updates
1187 for {
1188 select {
1189 case <-heartbeatTicker.C:
1190 // Send heartbeat event
1191 fmt.Fprintf(w, "event: heartbeat\n")
1192 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1193
1194 // Flush to send the heartbeat immediately
1195 if f, ok := w.(http.Flusher); ok {
1196 f.Flush()
1197 }
1198
1199 case <-ctx.Done():
1200 // Client disconnected
1201 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1202 return
1203
Philip Zeyligereab12de2025-05-14 02:35:53 +00001204 case _, ok := <-stateChan:
1205 if !ok {
1206 // Channel closed
1207 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1208 return
1209 }
1210
1211 // Get updated state
1212 state = s.getState()
1213
1214 // Send updated state after the state transition
1215 fmt.Fprintf(w, "event: state\n")
1216 fmt.Fprintf(w, "data: ")
1217 encoder.Encode(state)
1218 fmt.Fprintf(w, "\n\n")
1219
1220 // Flush to send the state immediately
1221 if f, ok := w.(http.Flusher); ok {
1222 f.Flush()
1223 }
1224
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001225 case newMessage, ok := <-messageChan:
1226 if !ok {
1227 // Channel closed
1228 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1229 return
1230 }
1231
1232 // Send the new message as an event
1233 fmt.Fprintf(w, "event: message\n")
1234 fmt.Fprintf(w, "data: ")
1235 encoder.Encode(newMessage)
1236 fmt.Fprintf(w, "\n\n")
1237
1238 // Get updated state
1239 state = s.getState()
1240
1241 // Send updated state after the message
1242 fmt.Fprintf(w, "event: state\n")
1243 fmt.Fprintf(w, "data: ")
1244 encoder.Encode(state)
1245 fmt.Fprintf(w, "\n\n")
1246
1247 // Flush to send the message and state immediately
1248 if f, ok := w.(http.Flusher); ok {
1249 f.Flush()
1250 }
1251 }
1252 }
1253}
1254
1255// Helper function to get the current state
1256func (s *Server) getState() State {
1257 serverMessageCount := s.agent.MessageCount()
1258 totalUsage := s.agent.TotalUsage()
1259
1260 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001261 StateVersion: 2,
1262 MessageCount: serverMessageCount,
1263 TotalUsage: &totalUsage,
1264 Hostname: s.hostname,
1265 WorkingDir: getWorkingDir(),
1266 // TODO: Rename this field to sketch-base?
1267 InitialCommit: s.agent.SketchGitBase(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001268 Title: s.agent.Title(),
1269 BranchName: s.agent.BranchName(),
1270 OS: s.agent.OS(),
1271 OutsideHostname: s.agent.OutsideHostname(),
1272 InsideHostname: s.hostname,
1273 OutsideOS: s.agent.OutsideOS(),
1274 InsideOS: s.agent.OS(),
1275 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1276 InsideWorkingDir: getWorkingDir(),
1277 GitOrigin: s.agent.GitOrigin(),
1278 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1279 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1280 SessionID: s.agent.SessionID(),
1281 SSHAvailable: s.sshAvailable,
1282 SSHError: s.sshError,
1283 InContainer: s.agent.IsInContainer(),
1284 FirstMessageIndex: s.agent.FirstMessageIndex(),
1285 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001286 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001287 }
1288}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001289
1290func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1291 if r.Method != "GET" {
1292 w.WriteHeader(http.StatusMethodNotAllowed)
1293 return
1294 }
1295
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001296 // Get the git repository root directory from agent
1297 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001298
1299 // Parse query parameters
1300 query := r.URL.Query()
1301 commit := query.Get("commit")
1302 from := query.Get("from")
1303 to := query.Get("to")
1304
1305 // If commit is specified, use commit^ and commit as from and to
1306 if commit != "" {
1307 from = commit + "^"
1308 to = commit
1309 }
1310
1311 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001312 if from == "" {
1313 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001314 return
1315 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001316 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001317
1318 // Call the git_tools function
1319 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1320 if err != nil {
1321 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1322 return
1323 }
1324
1325 // Return the result as JSON
1326 w.Header().Set("Content-Type", "application/json")
1327 if err := json.NewEncoder(w).Encode(diff); err != nil {
1328 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1329 return
1330 }
1331}
1332
1333func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1334 if r.Method != "GET" {
1335 w.WriteHeader(http.StatusMethodNotAllowed)
1336 return
1337 }
1338
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001339 // Get the git repository root directory from agent
1340 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001341
1342 // Parse query parameters
1343 hash := r.URL.Query().Get("hash")
1344 if hash == "" {
1345 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1346 return
1347 }
1348
1349 // Call the git_tools function
1350 show, err := git_tools.GitShow(repoDir, hash)
1351 if err != nil {
1352 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1353 return
1354 }
1355
1356 // Create a JSON response
1357 response := map[string]string{
1358 "hash": hash,
1359 "output": show,
1360 }
1361
1362 // Return the result as JSON
1363 w.Header().Set("Content-Type", "application/json")
1364 if err := json.NewEncoder(w).Encode(response); err != nil {
1365 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1366 return
1367 }
1368}
1369
1370func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1371 if r.Method != "GET" {
1372 w.WriteHeader(http.StatusMethodNotAllowed)
1373 return
1374 }
1375
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001376 // Get the git repository root directory and initial commit from agent
1377 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001378 initialCommit := s.agent.SketchGitBaseRef()
1379
1380 // Call the git_tools function
1381 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1382 if err != nil {
1383 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1384 return
1385 }
1386
1387 // Return the result as JSON
1388 w.Header().Set("Content-Type", "application/json")
1389 if err := json.NewEncoder(w).Encode(log); err != nil {
1390 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1391 return
1392 }
1393}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001394
1395func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1396 if r.Method != "GET" {
1397 w.WriteHeader(http.StatusMethodNotAllowed)
1398 return
1399 }
1400
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001401 // Get the git repository root directory from agent
1402 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001403
1404 // Parse query parameters
1405 query := r.URL.Query()
1406 path := query.Get("path")
1407
1408 // Check if path is provided
1409 if path == "" {
1410 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1411 return
1412 }
1413
1414 // Get file content using GitCat
1415 content, err := git_tools.GitCat(repoDir, path)
1416 if err != nil {
1417 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1418 return
1419 }
1420
1421 // Return the content as JSON for consistency with other endpoints
1422 w.Header().Set("Content-Type", "application/json")
1423 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1424 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1425 return
1426 }
1427}
1428
1429func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1430 if r.Method != "POST" {
1431 w.WriteHeader(http.StatusMethodNotAllowed)
1432 return
1433 }
1434
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001435 // Get the git repository root directory from agent
1436 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001437
1438 // Parse request body
1439 var requestBody struct {
1440 Path string `json:"path"`
1441 Content string `json:"content"`
1442 }
1443
1444 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1445 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1446 return
1447 }
1448 defer r.Body.Close()
1449
1450 // Check if path is provided
1451 if requestBody.Path == "" {
1452 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1453 return
1454 }
1455
1456 // Save file content using GitSaveFile
1457 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1458 if err != nil {
1459 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1460 return
1461 }
1462
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001463 // Auto-commit the changes
1464 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1465 if err != nil {
1466 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1467 return
1468 }
1469
1470 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001471 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1472 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1473 return
1474 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001475
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001476 // Return simple success response
1477 w.WriteHeader(http.StatusOK)
1478 w.Write([]byte("ok"))
1479}