blob: e223a6029a2395846364d3b932aff3b287b57a2f [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"
Earl Lee2e463fb2025-04-17 11:22:22 -070013 "log/slog"
14 "net/http"
Philip Zeyligera9710d72025-07-02 02:50:14 +000015 "net/http/httputil"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "net/http/pprof"
Philip Zeyligera9710d72025-07-02 02:50:14 +000017 "net/url"
Earl Lee2e463fb2025-04-17 11:22:22 -070018 "os"
19 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000020 "path/filepath"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070021 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "strconv"
23 "strings"
24 "sync"
25 "syscall"
26 "time"
27
28 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000029 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070030 "sketch.dev/embedded"
31 "sketch.dev/git_tools"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033 "sketch.dev/loop"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070034 "sketch.dev/loop/server/gzhandler"
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"`
bankseancad67b02025-06-27 21:57:05 +000084 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
86 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
87 SessionID string `json:"session_id"`
88 SSHAvailable bool `json:"ssh_available"`
89 SSHError string `json:"ssh_error,omitempty"`
90 InContainer bool `json:"in_container"`
91 FirstMessageIndex int `json:"first_message_index"`
92 AgentState string `json:"agent_state,omitempty"`
93 OutsideHostname string `json:"outside_hostname,omitempty"`
94 InsideHostname string `json:"inside_hostname,omitempty"`
95 OutsideOS string `json:"outside_os,omitempty"`
96 InsideOS string `json:"inside_os,omitempty"`
97 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
98 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -070099 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
100 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
101 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
102 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700103 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
104 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000105 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
106}
107
108// Port represents an open TCP port
109type Port struct {
110 Proto string `json:"proto"` // "tcp" or "udp"
111 Port uint16 `json:"port"` // port number
112 Process string `json:"process"` // optional process name
113 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700114}
115
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700116type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700117 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
118 HostAddr string `json:"host_addr"`
119
120 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000121 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
122 SSHServerIdentity []byte `json:"ssh_server_identity"`
123 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
124 SSHHostCertificate []byte `json:"ssh_host_certificate"`
125 SSHAvailable bool `json:"ssh_available"`
126 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700127}
128
Earl Lee2e463fb2025-04-17 11:22:22 -0700129// Server serves sketch HTTP. Server implements http.Handler.
130type Server struct {
131 mux *http.ServeMux
132 agent loop.CodingAgent
133 hostname string
134 logFile *os.File
135 // Mutex to protect terminalSessions
136 ptyMutex sync.Mutex
137 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000138 sshAvailable bool
139 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700140}
141
142func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000143 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
144 if port := s.ParsePortProxyHost(r.Host); port != "" {
145 s.proxyToPort(w, r, port)
146 return
147 }
148
Earl Lee2e463fb2025-04-17 11:22:22 -0700149 s.mux.ServeHTTP(w, r)
150}
151
Philip Zeyligera9710d72025-07-02 02:50:14 +0000152// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
153func (s *Server) ParsePortProxyHost(host string) string {
154 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
155 hostname := host
156 if idx := strings.LastIndex(host, ":"); idx > 0 {
157 hostname = host[:idx]
158 }
159
160 // Check if hostname matches p<port>.localhost pattern
161 if strings.HasSuffix(hostname, ".localhost") {
162 prefix := strings.TrimSuffix(hostname, ".localhost")
163 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
164 port := prefix[1:] // Remove 'p' prefix
165 // Basic validation - port should be numeric and in valid range
166 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
167 return port
168 }
169 }
170 }
171
172 return ""
173}
174
175// proxyToPort proxies the request to localhost:<port>
176func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
177 // Create a reverse proxy to localhost:<port>
178 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
179 if err != nil {
180 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
181 return
182 }
183
184 proxy := httputil.NewSingleHostReverseProxy(target)
185
186 // Customize the Director to modify the request
187 originalDirector := proxy.Director
188 proxy.Director = func(req *http.Request) {
189 originalDirector(req)
190 // Set the target host
191 req.URL.Host = target.Host
192 req.URL.Scheme = target.Scheme
193 req.Host = target.Host
194 }
195
196 // Handle proxy errors
197 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
198 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
199 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
200 }
201
202 proxy.ServeHTTP(w, r)
203}
204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205// New creates a new HTTP server.
206func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
207 s := &Server{
208 mux: http.NewServeMux(),
209 agent: agent,
210 hostname: getHostname(),
211 logFile: logFile,
212 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000213 sshAvailable: false,
214 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 }
216
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000217 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000218
219 // Git tool endpoints
220 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
221 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700222 s.mux.HandleFunc("/git/cat", s.handleGitCat)
223 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000224 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
225
Earl Lee2e463fb2025-04-17 11:22:22 -0700226 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
227 // Check if a specific commit hash was requested
228 commit := r.URL.Query().Get("commit")
229
230 // Get the diff, optionally for a specific commit
231 var diff string
232 var err error
233 if commit != "" {
234 // Validate the commit hash format
235 if !isValidGitSHA(commit) {
236 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
237 return
238 }
239
240 diff, err = agent.Diff(&commit)
241 } else {
242 diff, err = agent.Diff(nil)
243 }
244
245 if err != nil {
246 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
247 return
248 }
249
250 w.Header().Set("Content-Type", "text/plain")
251 w.Write([]byte(diff))
252 })
253
254 // Handler for initialization called by host sketch binary when inside docker.
255 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
256 defer func() {
257 if err := recover(); err != nil {
258 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
259
260 // Return an error response to the client
261 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
262 }
263 }()
264
265 if r.Method != "POST" {
266 http.Error(w, "POST required", http.StatusBadRequest)
267 return
268 }
269
270 body, err := io.ReadAll(r.Body)
271 r.Body.Close()
272 if err != nil {
273 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
274 return
275 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700276
277 m := &InitRequest{}
278 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700279 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
280 return
281 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700282
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000283 // Store SSH availability info
284 s.sshAvailable = m.SSHAvailable
285 s.sshError = m.SSHError
286
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700287 // Start the SSH server if the init request included ssh keys.
288 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
289 go func() {
290 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000291 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700292 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000293 // Update SSH error if server fails to start
294 s.sshAvailable = false
295 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700296 }
297 }()
298 }
299
Earl Lee2e463fb2025-04-17 11:22:22 -0700300 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700301 InDocker: true,
302 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700303 }
304 if err := agent.Init(ini); err != nil {
305 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
306 return
307 }
308 w.Header().Set("Content-Type", "application/json")
309 io.WriteString(w, "{}\n")
310 })
311
312 // Handler for /messages?start=N&end=M (start/end are optional)
313 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
314 w.Header().Set("Content-Type", "application/json")
315
316 // Extract query parameters for range
317 var start, end int
318 var err error
319
320 currentCount := agent.MessageCount()
321
322 startParam := r.URL.Query().Get("start")
323 if startParam != "" {
324 start, err = strconv.Atoi(startParam)
325 if err != nil {
326 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
327 return
328 }
329 }
330
331 endParam := r.URL.Query().Get("end")
332 if endParam != "" {
333 end, err = strconv.Atoi(endParam)
334 if err != nil {
335 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
336 return
337 }
338 } else {
339 end = currentCount
340 }
341
342 if start < 0 || start > end || end > currentCount {
343 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
344 return
345 }
346
347 start = max(0, start)
348 end = min(agent.MessageCount(), end)
349 messages := agent.Messages(start, end)
350
351 // Create a JSON encoder with indentation for pretty-printing
352 encoder := json.NewEncoder(w)
353 encoder.SetIndent("", " ") // Two spaces for each indentation level
354
355 err = encoder.Encode(messages)
356 if err != nil {
357 http.Error(w, err.Error(), http.StatusInternalServerError)
358 }
359 })
360
361 // Handler for /logs - displays the contents of the log file
362 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
363 if s.logFile == nil {
364 http.Error(w, "log file not set", http.StatusNotFound)
365 return
366 }
367 logContents, err := os.ReadFile(s.logFile.Name())
368 if err != nil {
369 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
370 return
371 }
372 w.Header().Set("Content-Type", "text/html; charset=utf-8")
373 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
374 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
375 fmt.Fprintf(w, "</body>\n</html>")
376 })
377
378 // Handler for /download - downloads both messages and status as a JSON file
379 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
380 // Set headers for file download
381 w.Header().Set("Content-Type", "application/octet-stream")
382
383 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
384 timestamp := time.Now().Format("20060102-150405")
385 filename := fmt.Sprintf("sketch-%s.json", timestamp)
386
387 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
388
389 // Get all messages
390 messageCount := agent.MessageCount()
391 messages := agent.Messages(0, messageCount)
392
393 // Get status information (usage and other metadata)
394 totalUsage := agent.TotalUsage()
395 hostname := getHostname()
396 workingDir := getWorkingDir()
397
398 // Create a combined structure with all information
399 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700400 Messages []loop.AgentMessage `json:"messages"`
401 MessageCount int `json:"message_count"`
402 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
403 Hostname string `json:"hostname"`
404 WorkingDir string `json:"working_dir"`
405 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700406 }{
407 Messages: messages,
408 MessageCount: messageCount,
409 TotalUsage: totalUsage,
410 Hostname: hostname,
411 WorkingDir: workingDir,
412 DownloadTime: time.Now().Format(time.RFC3339),
413 }
414
415 // Marshal the JSON with indentation for better readability
416 jsonData, err := json.MarshalIndent(downloadData, "", " ")
417 if err != nil {
418 http.Error(w, err.Error(), http.StatusInternalServerError)
419 return
420 }
421 w.Write(jsonData)
422 })
423
424 // The latter doesn't return until the number of messages has changed (from seen
425 // or from when this was called.)
426 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
427 pollParam := r.URL.Query().Get("poll")
428 seenParam := r.URL.Query().Get("seen")
429
430 // Get the client's current message count (if provided)
431 clientMessageCount := -1
432 var err error
433 if seenParam != "" {
434 clientMessageCount, err = strconv.Atoi(seenParam)
435 if err != nil {
436 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
437 return
438 }
439 }
440
441 serverMessageCount := agent.MessageCount()
442
443 // Let lazy clients not have to specify this.
444 if clientMessageCount == -1 {
445 clientMessageCount = serverMessageCount
446 }
447
448 if pollParam == "true" {
449 ch := make(chan string)
450 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700451 it := agent.NewIterator(r.Context(), clientMessageCount)
452 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700453 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700454 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 }()
456 select {
457 case <-r.Context().Done():
458 slog.DebugContext(r.Context(), "abandoned poll request")
459 return
460 case <-time.After(90 * time.Second):
461 // Let the user call /state again to get the latest to limit how long our long polls hang out.
462 slog.DebugContext(r.Context(), "longish poll request")
463 break
464 case <-ch:
465 break
466 }
467 }
468
Earl Lee2e463fb2025-04-17 11:22:22 -0700469 w.Header().Set("Content-Type", "application/json")
470
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000471 // Use the shared getState function
472 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700473
474 // Create a JSON encoder with indentation for pretty-printing
475 encoder := json.NewEncoder(w)
476 encoder.SetIndent("", " ") // Two spaces for each indentation level
477
478 err = encoder.Encode(state)
479 if err != nil {
480 http.Error(w, err.Error(), http.StatusInternalServerError)
481 }
482 })
483
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700484 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700485
486 // Terminal WebSocket handler
487 // Terminal endpoints - predefined terminals 1-9
488 // TODO: The UI doesn't actually know how to use terminals 2-9!
489 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
490 if r.Method != http.MethodGet {
491 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
492 return
493 }
494 pathParts := strings.Split(r.URL.Path, "/")
495 if len(pathParts) < 4 {
496 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
497 return
498 }
499
500 sessionID := pathParts[3]
501 // Validate that the terminal ID is between 1-9
502 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
503 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
504 return
505 }
506
507 s.handleTerminalEvents(w, r, sessionID)
508 })
509
510 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
511 if r.Method != http.MethodPost {
512 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
513 return
514 }
515 pathParts := strings.Split(r.URL.Path, "/")
516 if len(pathParts) < 4 {
517 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
518 return
519 }
520 sessionID := pathParts[3]
521 s.handleTerminalInput(w, r, sessionID)
522 })
523
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700524 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700525 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700526 webuiFS := embedded.WebUIFS()
527 appShell := "sketch-app-shell.html"
528 if r.URL.Query().Has("m") {
529 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700530 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700531 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700532 })
533
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700534 // Handler for /commit-description - returns the description of a git commit
535 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
536 if r.Method != http.MethodGet {
537 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
538 return
539 }
540
541 // Get the revision parameter
542 revision := r.URL.Query().Get("revision")
543 if revision == "" {
544 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
545 return
546 }
547
548 // Run git command to get commit description
549 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
550 // Use the working directory from the agent
551 cmd.Dir = s.agent.WorkingDir()
552
553 output, err := cmd.CombinedOutput()
554 if err != nil {
555 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
556 return
557 }
558
559 // Prepare the response
560 resp := map[string]string{
561 "description": strings.TrimSpace(string(output)),
562 }
563
564 w.Header().Set("Content-Type", "application/json")
565 if err := json.NewEncoder(w).Encode(resp); err != nil {
566 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
567 }
568 })
569
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000570 // Handler for /screenshot/{id} - serves screenshot images
571 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
572 if r.Method != http.MethodGet {
573 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
574 return
575 }
576
577 // Extract the screenshot ID from the path
578 pathParts := strings.Split(r.URL.Path, "/")
579 if len(pathParts) < 3 {
580 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
581 return
582 }
583
584 screenshotID := pathParts[2]
585
586 // Validate the ID format (prevent directory traversal)
587 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
588 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
589 return
590 }
591
592 // Get the screenshot file path
593 filePath := browse.GetScreenshotPath(screenshotID)
594
595 // Check if the file exists
596 if _, err := os.Stat(filePath); os.IsNotExist(err) {
597 http.Error(w, "Screenshot not found", http.StatusNotFound)
598 return
599 }
600
601 // Serve the file
602 w.Header().Set("Content-Type", "image/png")
603 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
604 http.ServeFile(w, r, filePath)
605 })
606
Earl Lee2e463fb2025-04-17 11:22:22 -0700607 // Handler for POST /chat
608 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
609 if r.Method != http.MethodPost {
610 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
611 return
612 }
613
614 // Parse the request body
615 var requestBody struct {
616 Message string `json:"message"`
617 }
618
619 decoder := json.NewDecoder(r.Body)
620 if err := decoder.Decode(&requestBody); err != nil {
621 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
622 return
623 }
624 defer r.Body.Close()
625
626 if requestBody.Message == "" {
627 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
628 return
629 }
630
631 agent.UserMessage(r.Context(), requestBody.Message)
632
633 w.WriteHeader(http.StatusOK)
634 })
635
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000636 // Handler for POST /upload - uploads a file to /tmp
637 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
638 if r.Method != http.MethodPost {
639 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
640 return
641 }
642
643 // Limit to 10MB file size
644 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
645
646 // Parse the multipart form
647 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
648 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
649 return
650 }
651
652 // Get the file from the multipart form
653 file, handler, err := r.FormFile("file")
654 if err != nil {
655 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
656 return
657 }
658 defer file.Close()
659
660 // Generate a unique ID (8 random bytes converted to 16 hex chars)
661 randBytes := make([]byte, 8)
662 if _, err := rand.Read(randBytes); err != nil {
663 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
664 return
665 }
666
667 // Get file extension from the original filename
668 ext := filepath.Ext(handler.Filename)
669
670 // Create a unique filename in the /tmp directory
671 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
672
673 // Create the destination file
674 destFile, err := os.Create(filename)
675 if err != nil {
676 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
677 return
678 }
679 defer destFile.Close()
680
681 // Copy the file contents to the destination file
682 if _, err := io.Copy(destFile, file); err != nil {
683 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
684 return
685 }
686
687 // Return the path to the saved file
688 w.Header().Set("Content-Type", "application/json")
689 json.NewEncoder(w).Encode(map[string]string{"path": filename})
690 })
691
Earl Lee2e463fb2025-04-17 11:22:22 -0700692 // Handler for /cancel - cancels the current inner loop in progress
693 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
694 if r.Method != http.MethodPost {
695 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
696 return
697 }
698
699 // Parse the request body (optional)
700 var requestBody struct {
701 Reason string `json:"reason"`
702 ToolCallID string `json:"tool_call_id"`
703 }
704
705 decoder := json.NewDecoder(r.Body)
706 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
707 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
708 return
709 }
710 defer r.Body.Close()
711
712 cancelReason := "user requested cancellation"
713 if requestBody.Reason != "" {
714 cancelReason = requestBody.Reason
715 }
716
717 if requestBody.ToolCallID != "" {
718 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
719 if err != nil {
720 http.Error(w, err.Error(), http.StatusBadRequest)
721 return
722 }
723 // Return a success response
724 w.Header().Set("Content-Type", "application/json")
725 json.NewEncoder(w).Encode(map[string]string{
726 "status": "cancelled",
727 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700728 "reason": cancelReason,
729 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700730 return
731 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000732 // Call the CancelTurn method
733 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700734 // Return a success response
735 w.Header().Set("Content-Type", "application/json")
736 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
737 })
738
Pokey Rule397871d2025-05-19 15:02:45 +0100739 // Handler for /end - shuts down the inner sketch process
740 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
741 if r.Method != http.MethodPost {
742 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
743 return
744 }
745
746 // Parse the request body (optional)
747 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700748 Reason string `json:"reason"`
749 Happy *bool `json:"happy,omitempty"`
750 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100751 }
752
753 decoder := json.NewDecoder(r.Body)
754 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
755 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
756 return
757 }
758 defer r.Body.Close()
759
760 endReason := "user requested end of session"
761 if requestBody.Reason != "" {
762 endReason = requestBody.Reason
763 }
764
765 // Send success response before exiting
766 w.Header().Set("Content-Type", "application/json")
767 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
768 if f, ok := w.(http.Flusher); ok {
769 f.Flush()
770 }
771
772 // Log that we're shutting down
773 slog.Info("Ending session", "reason", endReason)
774
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000775 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100776 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000777 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100778 os.Exit(0)
779 }()
780 })
781
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 debugMux := initDebugMux()
783 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
784 debugMux.ServeHTTP(w, r)
785 })
786
787 return s, nil
788}
789
790// Utility functions
791func getHostname() string {
792 hostname, err := os.Hostname()
793 if err != nil {
794 return "unknown"
795 }
796 return hostname
797}
798
799func getWorkingDir() string {
800 wd, err := os.Getwd()
801 if err != nil {
802 return "unknown"
803 }
804 return wd
805}
806
807// createTerminalSession creates a new terminal session with the given ID
808func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
809 // Start a new shell process
810 shellPath := getShellPath()
811 cmd := exec.Command(shellPath)
812
813 // Get working directory from the agent if possible
814 workDir := getWorkingDir()
815 cmd.Dir = workDir
816
817 // Set up environment
818 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
819
820 // Start the command with a pty
821 ptmx, err := pty.Start(cmd)
822 if err != nil {
823 slog.Error("Failed to start pty", "error", err)
824 return nil, err
825 }
826
827 // Create the terminal session
828 session := &terminalSession{
829 pty: ptmx,
830 eventsClients: make(map[chan []byte]bool),
831 cmd: cmd,
832 }
833
834 // Start goroutine to read from pty and broadcast to all connected SSE clients
835 go s.readFromPtyAndBroadcast(sessionID, session)
836
837 return session, nil
838} // handleTerminalEvents handles SSE connections for terminal output
839func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
840 // Check if the session exists, if not, create it
841 s.ptyMutex.Lock()
842 session, exists := s.terminalSessions[sessionID]
843
844 if !exists {
845 // Create a new terminal session
846 var err error
847 session, err = s.createTerminalSession(sessionID)
848 if err != nil {
849 s.ptyMutex.Unlock()
850 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
851 return
852 }
853
854 // Store the new session
855 s.terminalSessions[sessionID] = session
856 }
857 s.ptyMutex.Unlock()
858
859 // Set headers for SSE
860 w.Header().Set("Content-Type", "text/event-stream")
861 w.Header().Set("Cache-Control", "no-cache")
862 w.Header().Set("Connection", "keep-alive")
863 w.Header().Set("Access-Control-Allow-Origin", "*")
864
865 // Create a channel for this client
866 events := make(chan []byte, 4096) // Buffer to prevent blocking
867
868 // Register this client's channel
869 session.eventsClientsMutex.Lock()
870 clientID := session.lastEventClientID + 1
871 session.lastEventClientID = clientID
872 session.eventsClients[events] = true
873 session.eventsClientsMutex.Unlock()
874
875 // When the client disconnects, remove their channel
876 defer func() {
877 session.eventsClientsMutex.Lock()
878 delete(session.eventsClients, events)
879 close(events)
880 session.eventsClientsMutex.Unlock()
881 }()
882
883 // Flush to send headers to client immediately
884 if f, ok := w.(http.Flusher); ok {
885 f.Flush()
886 }
887
888 // Send events to the client as they arrive
889 for {
890 select {
891 case <-r.Context().Done():
892 return
893 case data := <-events:
894 // Format as SSE with base64 encoding
895 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
896
897 // Flush the data immediately
898 if f, ok := w.(http.Flusher); ok {
899 f.Flush()
900 }
901 }
902 }
903}
904
905// handleTerminalInput processes input to the terminal
906func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
907 // Check if the session exists
908 s.ptyMutex.Lock()
909 session, exists := s.terminalSessions[sessionID]
910 s.ptyMutex.Unlock()
911
912 if !exists {
913 http.Error(w, "Terminal session not found", http.StatusNotFound)
914 return
915 }
916
917 // Read the request body (terminal input or resize command)
918 body, err := io.ReadAll(r.Body)
919 if err != nil {
920 http.Error(w, "Failed to read request body", http.StatusBadRequest)
921 return
922 }
923
924 // Check if it's a resize message
925 if len(body) > 0 && body[0] == '{' {
926 var msg TerminalMessage
927 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
928 if msg.Cols > 0 && msg.Rows > 0 {
929 pty.Setsize(session.pty, &pty.Winsize{
930 Cols: msg.Cols,
931 Rows: msg.Rows,
932 })
933
934 // Respond with success
935 w.WriteHeader(http.StatusOK)
936 return
937 }
938 }
939 }
940
941 // Regular terminal input
942 _, err = session.pty.Write(body)
943 if err != nil {
944 slog.Error("Failed to write to pty", "error", err)
945 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
946 return
947 }
948
949 // Respond with success
950 w.WriteHeader(http.StatusOK)
951}
952
953// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
954func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
955 buf := make([]byte, 4096)
956 defer func() {
957 // Clean up when done
958 s.ptyMutex.Lock()
959 delete(s.terminalSessions, sessionID)
960 s.ptyMutex.Unlock()
961
962 // Close the PTY
963 session.pty.Close()
964
965 // Ensure process is terminated
966 if session.cmd.Process != nil {
967 session.cmd.Process.Signal(syscall.SIGTERM)
968 time.Sleep(100 * time.Millisecond)
969 session.cmd.Process.Kill()
970 }
971
972 // Close all client channels
973 session.eventsClientsMutex.Lock()
974 for ch := range session.eventsClients {
975 delete(session.eventsClients, ch)
976 close(ch)
977 }
978 session.eventsClientsMutex.Unlock()
979 }()
980
981 for {
982 n, err := session.pty.Read(buf)
983 if err != nil {
984 if err != io.EOF {
985 slog.Error("Failed to read from pty", "error", err)
986 }
987 break
988 }
989
990 // Make a copy of the data for each client
991 data := make([]byte, n)
992 copy(data, buf[:n])
993
994 // Broadcast to all connected clients
995 session.eventsClientsMutex.Lock()
996 for ch := range session.eventsClients {
997 // Try to send, but don't block if channel is full
998 select {
999 case ch <- data:
1000 default:
1001 // Channel is full, drop the message for this client
1002 }
1003 }
1004 session.eventsClientsMutex.Unlock()
1005 }
1006}
1007
1008// getShellPath returns the path to the shell to use
1009func getShellPath() string {
1010 // Try to use the user's preferred shell
1011 shell := os.Getenv("SHELL")
1012 if shell != "" {
1013 return shell
1014 }
1015
1016 // Default to bash on Unix-like systems
1017 if _, err := os.Stat("/bin/bash"); err == nil {
1018 return "/bin/bash"
1019 }
1020
1021 // Fall back to sh
1022 return "/bin/sh"
1023}
1024
1025func initDebugMux() *http.ServeMux {
1026 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001027 build := "unknown build"
1028 bi, ok := debug.ReadBuildInfo()
1029 if ok {
1030 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1031 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001032 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1033 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001034 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 fmt.Fprintf(w, `<!doctype html>
1036 <html><head><title>sketch debug</title></head><body>
1037 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001038 pid %d<br>
1039 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001040 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001041 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1042 <li><a href="pprof/profile">pprof/profile</a></li>
1043 <li><a href="pprof/symbol">pprof/symbol</a></li>
1044 <li><a href="pprof/trace">pprof/trace</a></li>
1045 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001046 </ul>
1047 </body>
1048 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001049 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 })
1051 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1052 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1053 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1054 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1055 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1056 return mux
1057}
1058
1059// isValidGitSHA validates if a string looks like a valid git SHA hash.
1060// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1061func isValidGitSHA(sha string) bool {
1062 // Git SHA must be a hexadecimal string with at least 4 characters
1063 if len(sha) < 4 || len(sha) > 40 {
1064 return false
1065 }
1066
1067 // Check if the string only contains hexadecimal characters
1068 for _, char := range sha {
1069 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1070 return false
1071 }
1072 }
1073
1074 return true
1075}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001076
1077// /stream?from=N endpoint for Server-Sent Events
1078func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1079 w.Header().Set("Content-Type", "text/event-stream")
1080 w.Header().Set("Cache-Control", "no-cache")
1081 w.Header().Set("Connection", "keep-alive")
1082 w.Header().Set("Access-Control-Allow-Origin", "*")
1083
1084 // Extract the 'from' parameter
1085 fromParam := r.URL.Query().Get("from")
1086 var fromIndex int
1087 var err error
1088 if fromParam != "" {
1089 fromIndex, err = strconv.Atoi(fromParam)
1090 if err != nil {
1091 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1092 return
1093 }
1094 }
1095
1096 // Ensure 'from' is valid
1097 currentCount := s.agent.MessageCount()
1098 if fromIndex < 0 {
1099 fromIndex = 0
1100 } else if fromIndex > currentCount {
1101 fromIndex = currentCount
1102 }
1103
1104 // Send the current state immediately
1105 state := s.getState()
1106
1107 // Create JSON encoder
1108 encoder := json.NewEncoder(w)
1109
1110 // Send state as an event
1111 fmt.Fprintf(w, "event: state\n")
1112 fmt.Fprintf(w, "data: ")
1113 encoder.Encode(state)
1114 fmt.Fprintf(w, "\n\n")
1115
1116 if f, ok := w.(http.Flusher); ok {
1117 f.Flush()
1118 }
1119
1120 // Create a context for the SSE stream
1121 ctx := r.Context()
1122
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001123 // Setup heartbeat timer
1124 heartbeatTicker := time.NewTicker(45 * time.Second)
1125 defer heartbeatTicker.Stop()
1126
1127 // Create a channel for messages
1128 messageChan := make(chan *loop.AgentMessage, 10)
1129
Philip Zeyligereab12de2025-05-14 02:35:53 +00001130 // Create a channel for state transitions
1131 stateChan := make(chan *loop.StateTransition, 10)
1132
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001133 // Start a goroutine to read messages without blocking the heartbeat
1134 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001135 // Create an iterator to receive new messages as they arrive
1136 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1137 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001138 defer close(messageChan)
1139 for {
1140 // This can block, but it's in its own goroutine
1141 newMessage := iterator.Next()
1142 if newMessage == nil {
1143 // No message available (likely due to context cancellation)
1144 slog.InfoContext(ctx, "No more messages available, ending message stream")
1145 return
1146 }
1147
1148 select {
1149 case messageChan <- newMessage:
1150 // Message sent to channel
1151 case <-ctx.Done():
1152 // Context cancelled
1153 return
1154 }
1155 }
1156 }()
1157
Philip Zeyligereab12de2025-05-14 02:35:53 +00001158 // Start a goroutine to read state transitions
1159 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001160 // Create an iterator to receive state transitions
1161 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1162 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001163 defer close(stateChan)
1164 for {
1165 // This can block, but it's in its own goroutine
1166 newTransition := stateIterator.Next()
1167 if newTransition == nil {
1168 // No transition available (likely due to context cancellation)
1169 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1170 return
1171 }
1172
1173 select {
1174 case stateChan <- newTransition:
1175 // Transition sent to channel
1176 case <-ctx.Done():
1177 // Context cancelled
1178 return
1179 }
1180 }
1181 }()
1182
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001183 // Stay connected and stream real-time updates
1184 for {
1185 select {
1186 case <-heartbeatTicker.C:
1187 // Send heartbeat event
1188 fmt.Fprintf(w, "event: heartbeat\n")
1189 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1190
1191 // Flush to send the heartbeat immediately
1192 if f, ok := w.(http.Flusher); ok {
1193 f.Flush()
1194 }
1195
1196 case <-ctx.Done():
1197 // Client disconnected
1198 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1199 return
1200
Philip Zeyligereab12de2025-05-14 02:35:53 +00001201 case _, ok := <-stateChan:
1202 if !ok {
1203 // Channel closed
1204 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1205 return
1206 }
1207
1208 // Get updated state
1209 state = s.getState()
1210
1211 // Send updated state after the state transition
1212 fmt.Fprintf(w, "event: state\n")
1213 fmt.Fprintf(w, "data: ")
1214 encoder.Encode(state)
1215 fmt.Fprintf(w, "\n\n")
1216
1217 // Flush to send the state immediately
1218 if f, ok := w.(http.Flusher); ok {
1219 f.Flush()
1220 }
1221
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001222 case newMessage, ok := <-messageChan:
1223 if !ok {
1224 // Channel closed
1225 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1226 return
1227 }
1228
1229 // Send the new message as an event
1230 fmt.Fprintf(w, "event: message\n")
1231 fmt.Fprintf(w, "data: ")
1232 encoder.Encode(newMessage)
1233 fmt.Fprintf(w, "\n\n")
1234
1235 // Get updated state
1236 state = s.getState()
1237
1238 // Send updated state after the message
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 message and state immediately
1245 if f, ok := w.(http.Flusher); ok {
1246 f.Flush()
1247 }
1248 }
1249 }
1250}
1251
1252// Helper function to get the current state
1253func (s *Server) getState() State {
1254 serverMessageCount := s.agent.MessageCount()
1255 totalUsage := s.agent.TotalUsage()
1256
Philip Zeyliger64f60462025-06-16 13:57:10 -07001257 // Get diff stats
1258 diffAdded, diffRemoved := s.agent.DiffStats()
1259
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001260 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001261 StateVersion: 2,
1262 MessageCount: serverMessageCount,
1263 TotalUsage: &totalUsage,
1264 Hostname: s.hostname,
1265 WorkingDir: getWorkingDir(),
1266 // TODO: Rename this field to sketch-base?
1267 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001268 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001269 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001270 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001271 OS: s.agent.OS(),
1272 OutsideHostname: s.agent.OutsideHostname(),
1273 InsideHostname: s.hostname,
1274 OutsideOS: s.agent.OutsideOS(),
1275 InsideOS: s.agent.OS(),
1276 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1277 InsideWorkingDir: getWorkingDir(),
1278 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001279 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001280 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1281 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1282 SessionID: s.agent.SessionID(),
1283 SSHAvailable: s.sshAvailable,
1284 SSHError: s.sshError,
1285 InContainer: s.agent.IsInContainer(),
1286 FirstMessageIndex: s.agent.FirstMessageIndex(),
1287 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001288 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001289 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001290 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001291 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001292 DiffLinesAdded: diffAdded,
1293 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001294 OpenPorts: s.getOpenPorts(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001295 }
1296}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001297
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001298// getOpenPorts retrieves the current open ports from the agent
1299func (s *Server) getOpenPorts() []Port {
1300 ports := s.agent.GetPorts()
1301 if ports == nil {
1302 return nil
1303 }
1304
1305 result := make([]Port, len(ports))
1306 for i, port := range ports {
1307 result[i] = Port{
1308 Proto: port.Proto,
1309 Port: port.Port,
1310 Process: port.Process,
1311 Pid: port.Pid,
1312 }
1313 }
1314 return result
1315}
1316
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001317func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1318 if r.Method != "GET" {
1319 w.WriteHeader(http.StatusMethodNotAllowed)
1320 return
1321 }
1322
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001323 // Get the git repository root directory from agent
1324 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001325
1326 // Parse query parameters
1327 query := r.URL.Query()
1328 commit := query.Get("commit")
1329 from := query.Get("from")
1330 to := query.Get("to")
1331
1332 // If commit is specified, use commit^ and commit as from and to
1333 if commit != "" {
1334 from = commit + "^"
1335 to = commit
1336 }
1337
1338 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001339 if from == "" {
1340 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001341 return
1342 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001343 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001344
1345 // Call the git_tools function
1346 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1347 if err != nil {
1348 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1349 return
1350 }
1351
1352 // Return the result as JSON
1353 w.Header().Set("Content-Type", "application/json")
1354 if err := json.NewEncoder(w).Encode(diff); err != nil {
1355 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1356 return
1357 }
1358}
1359
1360func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1361 if r.Method != "GET" {
1362 w.WriteHeader(http.StatusMethodNotAllowed)
1363 return
1364 }
1365
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001366 // Get the git repository root directory from agent
1367 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001368
1369 // Parse query parameters
1370 hash := r.URL.Query().Get("hash")
1371 if hash == "" {
1372 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1373 return
1374 }
1375
1376 // Call the git_tools function
1377 show, err := git_tools.GitShow(repoDir, hash)
1378 if err != nil {
1379 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1380 return
1381 }
1382
1383 // Create a JSON response
1384 response := map[string]string{
1385 "hash": hash,
1386 "output": show,
1387 }
1388
1389 // Return the result as JSON
1390 w.Header().Set("Content-Type", "application/json")
1391 if err := json.NewEncoder(w).Encode(response); err != nil {
1392 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1393 return
1394 }
1395}
1396
1397func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1398 if r.Method != "GET" {
1399 w.WriteHeader(http.StatusMethodNotAllowed)
1400 return
1401 }
1402
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001403 // Get the git repository root directory and initial commit from agent
1404 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001405 initialCommit := s.agent.SketchGitBaseRef()
1406
1407 // Call the git_tools function
1408 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1409 if err != nil {
1410 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1411 return
1412 }
1413
1414 // Return the result as JSON
1415 w.Header().Set("Content-Type", "application/json")
1416 if err := json.NewEncoder(w).Encode(log); err != nil {
1417 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1418 return
1419 }
1420}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001421
1422func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1423 if r.Method != "GET" {
1424 w.WriteHeader(http.StatusMethodNotAllowed)
1425 return
1426 }
1427
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001428 // Get the git repository root directory from agent
1429 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001430
1431 // Parse query parameters
1432 query := r.URL.Query()
1433 path := query.Get("path")
1434
1435 // Check if path is provided
1436 if path == "" {
1437 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1438 return
1439 }
1440
1441 // Get file content using GitCat
1442 content, err := git_tools.GitCat(repoDir, path)
1443 if err != nil {
1444 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1445 return
1446 }
1447
1448 // Return the content as JSON for consistency with other endpoints
1449 w.Header().Set("Content-Type", "application/json")
1450 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1451 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1452 return
1453 }
1454}
1455
1456func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1457 if r.Method != "POST" {
1458 w.WriteHeader(http.StatusMethodNotAllowed)
1459 return
1460 }
1461
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001462 // Get the git repository root directory from agent
1463 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001464
1465 // Parse request body
1466 var requestBody struct {
1467 Path string `json:"path"`
1468 Content string `json:"content"`
1469 }
1470
1471 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1472 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1473 return
1474 }
1475 defer r.Body.Close()
1476
1477 // Check if path is provided
1478 if requestBody.Path == "" {
1479 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1480 return
1481 }
1482
1483 // Save file content using GitSaveFile
1484 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1485 if err != nil {
1486 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1487 return
1488 }
1489
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001490 // Auto-commit the changes
1491 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1492 if err != nil {
1493 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1494 return
1495 }
1496
1497 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001498 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1499 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1500 return
1501 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001502
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001503 // Return simple success response
1504 w.WriteHeader(http.StatusOK)
1505 w.Write([]byte("ok"))
1506}