blob: 0204a38b7883628f1e712fee9555e625f4fd56ec [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"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070077 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070078 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000079 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070080 Hostname string `json:"hostname"` // deprecated
81 WorkingDir string `json:"working_dir"` // deprecated
82 OS string `json:"os"` // deprecated
83 GitOrigin string `json:"git_origin,omitempty"`
84 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
85 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
86 SessionID string `json:"session_id"`
87 SSHAvailable bool `json:"ssh_available"`
88 SSHError string `json:"ssh_error,omitempty"`
89 InContainer bool `json:"in_container"`
90 FirstMessageIndex int `json:"first_message_index"`
91 AgentState string `json:"agent_state,omitempty"`
92 OutsideHostname string `json:"outside_hostname,omitempty"`
93 InsideHostname string `json:"inside_hostname,omitempty"`
94 OutsideOS string `json:"outside_os,omitempty"`
95 InsideOS string `json:"inside_os,omitempty"`
96 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
97 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070098 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
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
Sean McCullough138ec242025-06-02 22:42:06 +0000245 // Handler for /port-events - returns recent port change events
246 s.mux.HandleFunc("/port-events", func(w http.ResponseWriter, r *http.Request) {
247 if r.Method != http.MethodGet {
248 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
249 return
250 }
251
252 w.Header().Set("Content-Type", "application/json")
253
254 // Get the 'since' query parameter for filtering events
255 sinceParam := r.URL.Query().Get("since")
256 var events []loop.PortEvent
257
258 // Get port monitor from agent
259 portMonitor := agent.GetPortMonitor()
260 if portMonitor == nil {
261 // Return empty array if port monitor not available
262 events = []loop.PortEvent{}
263 } else if sinceParam != "" {
264 // Parse the since timestamp
265 sinceTime, err := time.Parse(time.RFC3339, sinceParam)
266 if err != nil {
267 http.Error(w, fmt.Sprintf("Invalid 'since' timestamp format: %v", err), http.StatusBadRequest)
268 return
269 }
270 events = portMonitor.GetRecentEvents(sinceTime)
271 } else {
272 // Return all recent events
273 events = portMonitor.GetAllRecentEvents()
274 }
275
276 // Encode and return the events
277 if err := json.NewEncoder(w).Encode(events); err != nil {
278 slog.ErrorContext(r.Context(), "Error encoding port events response", slog.Any("err", err))
279 http.Error(w, "Internal server error", http.StatusInternalServerError)
280 }
281 })
282
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 // Handler for /messages?start=N&end=M (start/end are optional)
284 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
285 w.Header().Set("Content-Type", "application/json")
286
287 // Extract query parameters for range
288 var start, end int
289 var err error
290
291 currentCount := agent.MessageCount()
292
293 startParam := r.URL.Query().Get("start")
294 if startParam != "" {
295 start, err = strconv.Atoi(startParam)
296 if err != nil {
297 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
298 return
299 }
300 }
301
302 endParam := r.URL.Query().Get("end")
303 if endParam != "" {
304 end, err = strconv.Atoi(endParam)
305 if err != nil {
306 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
307 return
308 }
309 } else {
310 end = currentCount
311 }
312
313 if start < 0 || start > end || end > currentCount {
314 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
315 return
316 }
317
318 start = max(0, start)
319 end = min(agent.MessageCount(), end)
320 messages := agent.Messages(start, end)
321
322 // Create a JSON encoder with indentation for pretty-printing
323 encoder := json.NewEncoder(w)
324 encoder.SetIndent("", " ") // Two spaces for each indentation level
325
326 err = encoder.Encode(messages)
327 if err != nil {
328 http.Error(w, err.Error(), http.StatusInternalServerError)
329 }
330 })
331
332 // Handler for /logs - displays the contents of the log file
333 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
334 if s.logFile == nil {
335 http.Error(w, "log file not set", http.StatusNotFound)
336 return
337 }
338 logContents, err := os.ReadFile(s.logFile.Name())
339 if err != nil {
340 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
341 return
342 }
343 w.Header().Set("Content-Type", "text/html; charset=utf-8")
344 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
345 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
346 fmt.Fprintf(w, "</body>\n</html>")
347 })
348
349 // Handler for /download - downloads both messages and status as a JSON file
350 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
351 // Set headers for file download
352 w.Header().Set("Content-Type", "application/octet-stream")
353
354 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
355 timestamp := time.Now().Format("20060102-150405")
356 filename := fmt.Sprintf("sketch-%s.json", timestamp)
357
358 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
359
360 // Get all messages
361 messageCount := agent.MessageCount()
362 messages := agent.Messages(0, messageCount)
363
364 // Get status information (usage and other metadata)
365 totalUsage := agent.TotalUsage()
366 hostname := getHostname()
367 workingDir := getWorkingDir()
368
369 // Create a combined structure with all information
370 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700371 Messages []loop.AgentMessage `json:"messages"`
372 MessageCount int `json:"message_count"`
373 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
374 Hostname string `json:"hostname"`
375 WorkingDir string `json:"working_dir"`
376 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700377 }{
378 Messages: messages,
379 MessageCount: messageCount,
380 TotalUsage: totalUsage,
381 Hostname: hostname,
382 WorkingDir: workingDir,
383 DownloadTime: time.Now().Format(time.RFC3339),
384 }
385
386 // Marshal the JSON with indentation for better readability
387 jsonData, err := json.MarshalIndent(downloadData, "", " ")
388 if err != nil {
389 http.Error(w, err.Error(), http.StatusInternalServerError)
390 return
391 }
392 w.Write(jsonData)
393 })
394
395 // The latter doesn't return until the number of messages has changed (from seen
396 // or from when this was called.)
397 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
398 pollParam := r.URL.Query().Get("poll")
399 seenParam := r.URL.Query().Get("seen")
400
401 // Get the client's current message count (if provided)
402 clientMessageCount := -1
403 var err error
404 if seenParam != "" {
405 clientMessageCount, err = strconv.Atoi(seenParam)
406 if err != nil {
407 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
408 return
409 }
410 }
411
412 serverMessageCount := agent.MessageCount()
413
414 // Let lazy clients not have to specify this.
415 if clientMessageCount == -1 {
416 clientMessageCount = serverMessageCount
417 }
418
419 if pollParam == "true" {
420 ch := make(chan string)
421 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700422 it := agent.NewIterator(r.Context(), clientMessageCount)
423 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700424 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700425 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700426 }()
427 select {
428 case <-r.Context().Done():
429 slog.DebugContext(r.Context(), "abandoned poll request")
430 return
431 case <-time.After(90 * time.Second):
432 // Let the user call /state again to get the latest to limit how long our long polls hang out.
433 slog.DebugContext(r.Context(), "longish poll request")
434 break
435 case <-ch:
436 break
437 }
438 }
439
Earl Lee2e463fb2025-04-17 11:22:22 -0700440 w.Header().Set("Content-Type", "application/json")
441
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000442 // Use the shared getState function
443 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700444
445 // Create a JSON encoder with indentation for pretty-printing
446 encoder := json.NewEncoder(w)
447 encoder.SetIndent("", " ") // Two spaces for each indentation level
448
449 err = encoder.Encode(state)
450 if err != nil {
451 http.Error(w, err.Error(), http.StatusInternalServerError)
452 }
453 })
454
Philip Zeyliger176de792025-04-21 12:25:18 -0700455 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700456
457 // Terminal WebSocket handler
458 // Terminal endpoints - predefined terminals 1-9
459 // TODO: The UI doesn't actually know how to use terminals 2-9!
460 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
461 if r.Method != http.MethodGet {
462 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
463 return
464 }
465 pathParts := strings.Split(r.URL.Path, "/")
466 if len(pathParts) < 4 {
467 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
468 return
469 }
470
471 sessionID := pathParts[3]
472 // Validate that the terminal ID is between 1-9
473 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
474 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
475 return
476 }
477
478 s.handleTerminalEvents(w, r, sessionID)
479 })
480
481 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
482 if r.Method != http.MethodPost {
483 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
484 return
485 }
486 pathParts := strings.Split(r.URL.Path, "/")
487 if len(pathParts) < 4 {
488 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
489 return
490 }
491 sessionID := pathParts[3]
492 s.handleTerminalInput(w, r, sessionID)
493 })
494
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700495 // Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
Earl Lee2e463fb2025-04-17 11:22:22 -0700496 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700497 // Check URL parameters for interface selection
498 queryParams := r.URL.Query()
499
500 // Check if mobile interface is requested (?m parameter)
501 if queryParams.Has("m") {
502 // Serve the mobile-app-shell.html file
503 data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
504 if err != nil {
505 http.Error(w, "Mobile interface not found", http.StatusNotFound)
506 return
507 }
508 w.Header().Set("Content-Type", "text/html")
509 w.Write(data)
510 return
511 }
512
513 // Check if desktop interface is explicitly requested (?d parameter)
514 // or serve desktop by default
Sean McCullough86b56862025-04-18 13:04:03 -0700515 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700516 if err != nil {
517 http.Error(w, "File not found", http.StatusNotFound)
518 return
519 }
520 w.Header().Set("Content-Type", "text/html")
521 w.Write(data)
522 })
523
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700524 // Handler for /commit-description - returns the description of a git commit
525 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
526 if r.Method != http.MethodGet {
527 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
528 return
529 }
530
531 // Get the revision parameter
532 revision := r.URL.Query().Get("revision")
533 if revision == "" {
534 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
535 return
536 }
537
538 // Run git command to get commit description
539 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
540 // Use the working directory from the agent
541 cmd.Dir = s.agent.WorkingDir()
542
543 output, err := cmd.CombinedOutput()
544 if err != nil {
545 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
546 return
547 }
548
549 // Prepare the response
550 resp := map[string]string{
551 "description": strings.TrimSpace(string(output)),
552 }
553
554 w.Header().Set("Content-Type", "application/json")
555 if err := json.NewEncoder(w).Encode(resp); err != nil {
556 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
557 }
558 })
559
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000560 // Handler for /screenshot/{id} - serves screenshot images
561 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
562 if r.Method != http.MethodGet {
563 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
564 return
565 }
566
567 // Extract the screenshot ID from the path
568 pathParts := strings.Split(r.URL.Path, "/")
569 if len(pathParts) < 3 {
570 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
571 return
572 }
573
574 screenshotID := pathParts[2]
575
576 // Validate the ID format (prevent directory traversal)
577 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
578 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
579 return
580 }
581
582 // Get the screenshot file path
583 filePath := browse.GetScreenshotPath(screenshotID)
584
585 // Check if the file exists
586 if _, err := os.Stat(filePath); os.IsNotExist(err) {
587 http.Error(w, "Screenshot not found", http.StatusNotFound)
588 return
589 }
590
591 // Serve the file
592 w.Header().Set("Content-Type", "image/png")
593 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
594 http.ServeFile(w, r, filePath)
595 })
596
Earl Lee2e463fb2025-04-17 11:22:22 -0700597 // Handler for POST /chat
598 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
599 if r.Method != http.MethodPost {
600 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
601 return
602 }
603
604 // Parse the request body
605 var requestBody struct {
606 Message string `json:"message"`
607 }
608
609 decoder := json.NewDecoder(r.Body)
610 if err := decoder.Decode(&requestBody); err != nil {
611 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
612 return
613 }
614 defer r.Body.Close()
615
616 if requestBody.Message == "" {
617 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
618 return
619 }
620
621 agent.UserMessage(r.Context(), requestBody.Message)
622
623 w.WriteHeader(http.StatusOK)
624 })
625
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000626 // Handler for POST /upload - uploads a file to /tmp
627 s.mux.HandleFunc("/upload", 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 // Limit to 10MB file size
634 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
635
636 // Parse the multipart form
637 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
638 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
639 return
640 }
641
642 // Get the file from the multipart form
643 file, handler, err := r.FormFile("file")
644 if err != nil {
645 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
646 return
647 }
648 defer file.Close()
649
650 // Generate a unique ID (8 random bytes converted to 16 hex chars)
651 randBytes := make([]byte, 8)
652 if _, err := rand.Read(randBytes); err != nil {
653 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
654 return
655 }
656
657 // Get file extension from the original filename
658 ext := filepath.Ext(handler.Filename)
659
660 // Create a unique filename in the /tmp directory
661 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
662
663 // Create the destination file
664 destFile, err := os.Create(filename)
665 if err != nil {
666 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
667 return
668 }
669 defer destFile.Close()
670
671 // Copy the file contents to the destination file
672 if _, err := io.Copy(destFile, file); err != nil {
673 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
674 return
675 }
676
677 // Return the path to the saved file
678 w.Header().Set("Content-Type", "application/json")
679 json.NewEncoder(w).Encode(map[string]string{"path": filename})
680 })
681
Earl Lee2e463fb2025-04-17 11:22:22 -0700682 // Handler for /cancel - cancels the current inner loop in progress
683 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
684 if r.Method != http.MethodPost {
685 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
686 return
687 }
688
689 // Parse the request body (optional)
690 var requestBody struct {
691 Reason string `json:"reason"`
692 ToolCallID string `json:"tool_call_id"`
693 }
694
695 decoder := json.NewDecoder(r.Body)
696 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
697 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
698 return
699 }
700 defer r.Body.Close()
701
702 cancelReason := "user requested cancellation"
703 if requestBody.Reason != "" {
704 cancelReason = requestBody.Reason
705 }
706
707 if requestBody.ToolCallID != "" {
708 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
709 if err != nil {
710 http.Error(w, err.Error(), http.StatusBadRequest)
711 return
712 }
713 // Return a success response
714 w.Header().Set("Content-Type", "application/json")
715 json.NewEncoder(w).Encode(map[string]string{
716 "status": "cancelled",
717 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700718 "reason": cancelReason,
719 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700720 return
721 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000722 // Call the CancelTurn method
723 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700724 // Return a success response
725 w.Header().Set("Content-Type", "application/json")
726 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
727 })
728
Pokey Rule397871d2025-05-19 15:02:45 +0100729 // Handler for /end - shuts down the inner sketch process
730 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
731 if r.Method != http.MethodPost {
732 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
733 return
734 }
735
736 // Parse the request body (optional)
737 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700738 Reason string `json:"reason"`
739 Happy *bool `json:"happy,omitempty"`
740 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100741 }
742
743 decoder := json.NewDecoder(r.Body)
744 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
745 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
746 return
747 }
748 defer r.Body.Close()
749
750 endReason := "user requested end of session"
751 if requestBody.Reason != "" {
752 endReason = requestBody.Reason
753 }
754
755 // Send success response before exiting
756 w.Header().Set("Content-Type", "application/json")
757 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
758 if f, ok := w.(http.Flusher); ok {
759 f.Flush()
760 }
761
762 // Log that we're shutting down
763 slog.Info("Ending session", "reason", endReason)
764
Philip Zeyligerb5739402025-06-02 07:04:34 -0700765 // Wait for skaband clients that are waiting for end (with timeout)
Pokey Rule397871d2025-05-19 15:02:45 +0100766 go func() {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700767 startTime := time.Now()
768 // Wait up to 2 seconds for waiting clients to receive the end message
769 done := make(chan struct{})
770 go func() {
771 s.endWaitGroup.Wait()
772 close(done)
773 }()
774
775 select {
776 case <-done:
777 slog.Info("All waiting clients notified of end")
778 case <-time.After(2 * time.Second):
779 slog.Info("Timeout waiting for clients, proceeding with shutdown")
780 }
781
782 // Ensure we've been running for at least 100ms to allow response to be sent
783 elapsed := time.Since(startTime)
784 if elapsed < 100*time.Millisecond {
785 time.Sleep(100*time.Millisecond - elapsed)
786 }
787
Pokey Rule397871d2025-05-19 15:02:45 +0100788 os.Exit(0)
789 }()
790 })
791
Earl Lee2e463fb2025-04-17 11:22:22 -0700792 debugMux := initDebugMux()
793 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
794 debugMux.ServeHTTP(w, r)
795 })
796
797 return s, nil
798}
799
800// Utility functions
801func getHostname() string {
802 hostname, err := os.Hostname()
803 if err != nil {
804 return "unknown"
805 }
806 return hostname
807}
808
809func getWorkingDir() string {
810 wd, err := os.Getwd()
811 if err != nil {
812 return "unknown"
813 }
814 return wd
815}
816
817// createTerminalSession creates a new terminal session with the given ID
818func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
819 // Start a new shell process
820 shellPath := getShellPath()
821 cmd := exec.Command(shellPath)
822
823 // Get working directory from the agent if possible
824 workDir := getWorkingDir()
825 cmd.Dir = workDir
826
827 // Set up environment
828 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
829
830 // Start the command with a pty
831 ptmx, err := pty.Start(cmd)
832 if err != nil {
833 slog.Error("Failed to start pty", "error", err)
834 return nil, err
835 }
836
837 // Create the terminal session
838 session := &terminalSession{
839 pty: ptmx,
840 eventsClients: make(map[chan []byte]bool),
841 cmd: cmd,
842 }
843
844 // Start goroutine to read from pty and broadcast to all connected SSE clients
845 go s.readFromPtyAndBroadcast(sessionID, session)
846
847 return session, nil
848} // handleTerminalEvents handles SSE connections for terminal output
849func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
850 // Check if the session exists, if not, create it
851 s.ptyMutex.Lock()
852 session, exists := s.terminalSessions[sessionID]
853
854 if !exists {
855 // Create a new terminal session
856 var err error
857 session, err = s.createTerminalSession(sessionID)
858 if err != nil {
859 s.ptyMutex.Unlock()
860 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
861 return
862 }
863
864 // Store the new session
865 s.terminalSessions[sessionID] = session
866 }
867 s.ptyMutex.Unlock()
868
869 // Set headers for SSE
870 w.Header().Set("Content-Type", "text/event-stream")
871 w.Header().Set("Cache-Control", "no-cache")
872 w.Header().Set("Connection", "keep-alive")
873 w.Header().Set("Access-Control-Allow-Origin", "*")
874
875 // Create a channel for this client
876 events := make(chan []byte, 4096) // Buffer to prevent blocking
877
878 // Register this client's channel
879 session.eventsClientsMutex.Lock()
880 clientID := session.lastEventClientID + 1
881 session.lastEventClientID = clientID
882 session.eventsClients[events] = true
883 session.eventsClientsMutex.Unlock()
884
885 // When the client disconnects, remove their channel
886 defer func() {
887 session.eventsClientsMutex.Lock()
888 delete(session.eventsClients, events)
889 close(events)
890 session.eventsClientsMutex.Unlock()
891 }()
892
893 // Flush to send headers to client immediately
894 if f, ok := w.(http.Flusher); ok {
895 f.Flush()
896 }
897
898 // Send events to the client as they arrive
899 for {
900 select {
901 case <-r.Context().Done():
902 return
903 case data := <-events:
904 // Format as SSE with base64 encoding
905 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
906
907 // Flush the data immediately
908 if f, ok := w.(http.Flusher); ok {
909 f.Flush()
910 }
911 }
912 }
913}
914
915// handleTerminalInput processes input to the terminal
916func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
917 // Check if the session exists
918 s.ptyMutex.Lock()
919 session, exists := s.terminalSessions[sessionID]
920 s.ptyMutex.Unlock()
921
922 if !exists {
923 http.Error(w, "Terminal session not found", http.StatusNotFound)
924 return
925 }
926
927 // Read the request body (terminal input or resize command)
928 body, err := io.ReadAll(r.Body)
929 if err != nil {
930 http.Error(w, "Failed to read request body", http.StatusBadRequest)
931 return
932 }
933
934 // Check if it's a resize message
935 if len(body) > 0 && body[0] == '{' {
936 var msg TerminalMessage
937 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
938 if msg.Cols > 0 && msg.Rows > 0 {
939 pty.Setsize(session.pty, &pty.Winsize{
940 Cols: msg.Cols,
941 Rows: msg.Rows,
942 })
943
944 // Respond with success
945 w.WriteHeader(http.StatusOK)
946 return
947 }
948 }
949 }
950
951 // Regular terminal input
952 _, err = session.pty.Write(body)
953 if err != nil {
954 slog.Error("Failed to write to pty", "error", err)
955 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
956 return
957 }
958
959 // Respond with success
960 w.WriteHeader(http.StatusOK)
961}
962
963// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
964func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
965 buf := make([]byte, 4096)
966 defer func() {
967 // Clean up when done
968 s.ptyMutex.Lock()
969 delete(s.terminalSessions, sessionID)
970 s.ptyMutex.Unlock()
971
972 // Close the PTY
973 session.pty.Close()
974
975 // Ensure process is terminated
976 if session.cmd.Process != nil {
977 session.cmd.Process.Signal(syscall.SIGTERM)
978 time.Sleep(100 * time.Millisecond)
979 session.cmd.Process.Kill()
980 }
981
982 // Close all client channels
983 session.eventsClientsMutex.Lock()
984 for ch := range session.eventsClients {
985 delete(session.eventsClients, ch)
986 close(ch)
987 }
988 session.eventsClientsMutex.Unlock()
989 }()
990
991 for {
992 n, err := session.pty.Read(buf)
993 if err != nil {
994 if err != io.EOF {
995 slog.Error("Failed to read from pty", "error", err)
996 }
997 break
998 }
999
1000 // Make a copy of the data for each client
1001 data := make([]byte, n)
1002 copy(data, buf[:n])
1003
1004 // Broadcast to all connected clients
1005 session.eventsClientsMutex.Lock()
1006 for ch := range session.eventsClients {
1007 // Try to send, but don't block if channel is full
1008 select {
1009 case ch <- data:
1010 default:
1011 // Channel is full, drop the message for this client
1012 }
1013 }
1014 session.eventsClientsMutex.Unlock()
1015 }
1016}
1017
1018// getShellPath returns the path to the shell to use
1019func getShellPath() string {
1020 // Try to use the user's preferred shell
1021 shell := os.Getenv("SHELL")
1022 if shell != "" {
1023 return shell
1024 }
1025
1026 // Default to bash on Unix-like systems
1027 if _, err := os.Stat("/bin/bash"); err == nil {
1028 return "/bin/bash"
1029 }
1030
1031 // Fall back to sh
1032 return "/bin/sh"
1033}
1034
1035func initDebugMux() *http.ServeMux {
1036 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001037 build := "unknown build"
1038 bi, ok := debug.ReadBuildInfo()
1039 if ok {
1040 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1041 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001042 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1043 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001044 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001045 fmt.Fprintf(w, `<!doctype html>
1046 <html><head><title>sketch debug</title></head><body>
1047 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001048 pid %d<br>
1049 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 <ul>
1051 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
1052 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
1053 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
1054 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
1055 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1056 <li><a href="/debug/metrics">metrics</a></li>
1057 </ul>
1058 </body>
1059 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001060 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 })
1062 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1063 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1064 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1065 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1066 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1067 return mux
1068}
1069
1070// isValidGitSHA validates if a string looks like a valid git SHA hash.
1071// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1072func isValidGitSHA(sha string) bool {
1073 // Git SHA must be a hexadecimal string with at least 4 characters
1074 if len(sha) < 4 || len(sha) > 40 {
1075 return false
1076 }
1077
1078 // Check if the string only contains hexadecimal characters
1079 for _, char := range sha {
1080 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1081 return false
1082 }
1083 }
1084
1085 return true
1086}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001087
1088// /stream?from=N endpoint for Server-Sent Events
1089func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1090 w.Header().Set("Content-Type", "text/event-stream")
1091 w.Header().Set("Cache-Control", "no-cache")
1092 w.Header().Set("Connection", "keep-alive")
1093 w.Header().Set("Access-Control-Allow-Origin", "*")
1094
1095 // Extract the 'from' parameter
1096 fromParam := r.URL.Query().Get("from")
1097 var fromIndex int
1098 var err error
1099 if fromParam != "" {
1100 fromIndex, err = strconv.Atoi(fromParam)
1101 if err != nil {
1102 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1103 return
1104 }
1105 }
1106
Philip Zeyligerb5739402025-06-02 07:04:34 -07001107 // Check if this client is waiting for end
1108 waitForEnd := r.URL.Query().Get("wait_for_end") == "true"
1109 if waitForEnd {
1110 s.endWaitGroup.Add(1)
1111 defer func() {
1112 if waitForEnd {
1113 s.endWaitGroup.Done()
1114 }
1115 }()
1116 }
1117
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001118 // Ensure 'from' is valid
1119 currentCount := s.agent.MessageCount()
1120 if fromIndex < 0 {
1121 fromIndex = 0
1122 } else if fromIndex > currentCount {
1123 fromIndex = currentCount
1124 }
1125
1126 // Send the current state immediately
1127 state := s.getState()
1128
1129 // Create JSON encoder
1130 encoder := json.NewEncoder(w)
1131
1132 // Send state as an event
1133 fmt.Fprintf(w, "event: state\n")
1134 fmt.Fprintf(w, "data: ")
1135 encoder.Encode(state)
1136 fmt.Fprintf(w, "\n\n")
1137
1138 if f, ok := w.(http.Flusher); ok {
1139 f.Flush()
1140 }
1141
1142 // Create a context for the SSE stream
1143 ctx := r.Context()
1144
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001145 // Setup heartbeat timer
1146 heartbeatTicker := time.NewTicker(45 * time.Second)
1147 defer heartbeatTicker.Stop()
1148
1149 // Create a channel for messages
1150 messageChan := make(chan *loop.AgentMessage, 10)
1151
Philip Zeyligereab12de2025-05-14 02:35:53 +00001152 // Create a channel for state transitions
1153 stateChan := make(chan *loop.StateTransition, 10)
1154
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001155 // Start a goroutine to read messages without blocking the heartbeat
1156 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001157 // Create an iterator to receive new messages as they arrive
1158 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1159 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001160 defer close(messageChan)
1161 for {
1162 // This can block, but it's in its own goroutine
1163 newMessage := iterator.Next()
1164 if newMessage == nil {
1165 // No message available (likely due to context cancellation)
1166 slog.InfoContext(ctx, "No more messages available, ending message stream")
1167 return
1168 }
1169
1170 select {
1171 case messageChan <- newMessage:
1172 // Message sent to channel
1173 case <-ctx.Done():
1174 // Context cancelled
1175 return
1176 }
1177 }
1178 }()
1179
Philip Zeyligereab12de2025-05-14 02:35:53 +00001180 // Start a goroutine to read state transitions
1181 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001182 // Create an iterator to receive state transitions
1183 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1184 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001185 defer close(stateChan)
1186 for {
1187 // This can block, but it's in its own goroutine
1188 newTransition := stateIterator.Next()
1189 if newTransition == nil {
1190 // No transition available (likely due to context cancellation)
1191 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1192 return
1193 }
1194
1195 select {
1196 case stateChan <- newTransition:
1197 // Transition sent to channel
1198 case <-ctx.Done():
1199 // Context cancelled
1200 return
1201 }
1202 }
1203 }()
1204
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001205 // Stay connected and stream real-time updates
1206 for {
1207 select {
1208 case <-heartbeatTicker.C:
1209 // Send heartbeat event
1210 fmt.Fprintf(w, "event: heartbeat\n")
1211 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1212
1213 // Flush to send the heartbeat immediately
1214 if f, ok := w.(http.Flusher); ok {
1215 f.Flush()
1216 }
1217
1218 case <-ctx.Done():
1219 // Client disconnected
1220 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1221 return
1222
Philip Zeyligereab12de2025-05-14 02:35:53 +00001223 case _, ok := <-stateChan:
1224 if !ok {
1225 // Channel closed
1226 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1227 return
1228 }
1229
1230 // Get updated state
1231 state = s.getState()
1232
1233 // Send updated state after the state transition
1234 fmt.Fprintf(w, "event: state\n")
1235 fmt.Fprintf(w, "data: ")
1236 encoder.Encode(state)
1237 fmt.Fprintf(w, "\n\n")
1238
1239 // Flush to send the state immediately
1240 if f, ok := w.(http.Flusher); ok {
1241 f.Flush()
1242 }
1243
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001244 case newMessage, ok := <-messageChan:
1245 if !ok {
1246 // Channel closed
1247 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1248 return
1249 }
1250
1251 // Send the new message as an event
1252 fmt.Fprintf(w, "event: message\n")
1253 fmt.Fprintf(w, "data: ")
1254 encoder.Encode(newMessage)
1255 fmt.Fprintf(w, "\n\n")
1256
1257 // Get updated state
1258 state = s.getState()
1259
1260 // Send updated state after the message
1261 fmt.Fprintf(w, "event: state\n")
1262 fmt.Fprintf(w, "data: ")
1263 encoder.Encode(state)
1264 fmt.Fprintf(w, "\n\n")
1265
1266 // Flush to send the message and state immediately
1267 if f, ok := w.(http.Flusher); ok {
1268 f.Flush()
1269 }
1270 }
1271 }
1272}
1273
1274// Helper function to get the current state
1275func (s *Server) getState() State {
1276 serverMessageCount := s.agent.MessageCount()
1277 totalUsage := s.agent.TotalUsage()
1278
1279 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001280 StateVersion: 2,
1281 MessageCount: serverMessageCount,
1282 TotalUsage: &totalUsage,
1283 Hostname: s.hostname,
1284 WorkingDir: getWorkingDir(),
1285 // TODO: Rename this field to sketch-base?
1286 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001287 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001288 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001289 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001290 OS: s.agent.OS(),
1291 OutsideHostname: s.agent.OutsideHostname(),
1292 InsideHostname: s.hostname,
1293 OutsideOS: s.agent.OutsideOS(),
1294 InsideOS: s.agent.OS(),
1295 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1296 InsideWorkingDir: getWorkingDir(),
1297 GitOrigin: s.agent.GitOrigin(),
1298 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1299 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1300 SessionID: s.agent.SessionID(),
1301 SSHAvailable: s.sshAvailable,
1302 SSHError: s.sshError,
1303 InContainer: s.agent.IsInContainer(),
1304 FirstMessageIndex: s.agent.FirstMessageIndex(),
1305 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001306 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001307 }
1308}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001309
1310func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1311 if r.Method != "GET" {
1312 w.WriteHeader(http.StatusMethodNotAllowed)
1313 return
1314 }
1315
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001316 // Get the git repository root directory from agent
1317 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001318
1319 // Parse query parameters
1320 query := r.URL.Query()
1321 commit := query.Get("commit")
1322 from := query.Get("from")
1323 to := query.Get("to")
1324
1325 // If commit is specified, use commit^ and commit as from and to
1326 if commit != "" {
1327 from = commit + "^"
1328 to = commit
1329 }
1330
1331 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001332 if from == "" {
1333 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001334 return
1335 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001336 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001337
1338 // Call the git_tools function
1339 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1340 if err != nil {
1341 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1342 return
1343 }
1344
1345 // Return the result as JSON
1346 w.Header().Set("Content-Type", "application/json")
1347 if err := json.NewEncoder(w).Encode(diff); err != nil {
1348 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1349 return
1350 }
1351}
1352
1353func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1354 if r.Method != "GET" {
1355 w.WriteHeader(http.StatusMethodNotAllowed)
1356 return
1357 }
1358
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001359 // Get the git repository root directory from agent
1360 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001361
1362 // Parse query parameters
1363 hash := r.URL.Query().Get("hash")
1364 if hash == "" {
1365 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1366 return
1367 }
1368
1369 // Call the git_tools function
1370 show, err := git_tools.GitShow(repoDir, hash)
1371 if err != nil {
1372 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1373 return
1374 }
1375
1376 // Create a JSON response
1377 response := map[string]string{
1378 "hash": hash,
1379 "output": show,
1380 }
1381
1382 // Return the result as JSON
1383 w.Header().Set("Content-Type", "application/json")
1384 if err := json.NewEncoder(w).Encode(response); err != nil {
1385 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1386 return
1387 }
1388}
1389
1390func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1391 if r.Method != "GET" {
1392 w.WriteHeader(http.StatusMethodNotAllowed)
1393 return
1394 }
1395
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001396 // Get the git repository root directory and initial commit from agent
1397 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001398 initialCommit := s.agent.SketchGitBaseRef()
1399
1400 // Call the git_tools function
1401 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1402 if err != nil {
1403 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1404 return
1405 }
1406
1407 // Return the result as JSON
1408 w.Header().Set("Content-Type", "application/json")
1409 if err := json.NewEncoder(w).Encode(log); err != nil {
1410 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1411 return
1412 }
1413}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001414
1415func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1416 if r.Method != "GET" {
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 query parameters
1425 query := r.URL.Query()
1426 path := query.Get("path")
1427
1428 // Check if path is provided
1429 if path == "" {
1430 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1431 return
1432 }
1433
1434 // Get file content using GitCat
1435 content, err := git_tools.GitCat(repoDir, path)
1436 if err != nil {
1437 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1438 return
1439 }
1440
1441 // Return the content as JSON for consistency with other endpoints
1442 w.Header().Set("Content-Type", "application/json")
1443 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1444 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1445 return
1446 }
1447}
1448
1449func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1450 if r.Method != "POST" {
1451 w.WriteHeader(http.StatusMethodNotAllowed)
1452 return
1453 }
1454
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001455 // Get the git repository root directory from agent
1456 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001457
1458 // Parse request body
1459 var requestBody struct {
1460 Path string `json:"path"`
1461 Content string `json:"content"`
1462 }
1463
1464 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1465 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1466 return
1467 }
1468 defer r.Body.Close()
1469
1470 // Check if path is provided
1471 if requestBody.Path == "" {
1472 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1473 return
1474 }
1475
1476 // Save file content using GitSaveFile
1477 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1478 if err != nil {
1479 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1480 return
1481 }
1482
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001483 // Auto-commit the changes
1484 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1485 if err != nil {
1486 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1487 return
1488 }
1489
1490 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001491 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1492 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1493 return
1494 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001495
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001496 // Return simple success response
1497 w.WriteHeader(http.StatusOK)
1498 w.Write([]byte("ok"))
1499}