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