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