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