blob: 79e9e547bb26866f5f4629673a0d8695838fcbc1 [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"
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +000010 "errors"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "fmt"
12 "html"
13 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "log/slog"
15 "net/http"
Philip Zeyligera9710d72025-07-02 02:50:14 +000016 "net/http/httputil"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "net/http/pprof"
Philip Zeyligera9710d72025-07-02 02:50:14 +000018 "net/url"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "os"
20 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000021 "path/filepath"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070022 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "strconv"
24 "strings"
25 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070026 "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
banksean5ab8fb82025-07-09 12:34:55 -0700106 TokenContextWindow int `json:"token_context_window,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000107}
108
109// Port represents an open TCP port
110type Port struct {
111 Proto string `json:"proto"` // "tcp" or "udp"
112 Port uint16 `json:"port"` // port number
113 Process string `json:"process"` // optional process name
114 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700115}
116
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700117type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700118 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
119 HostAddr string `json:"host_addr"`
120
121 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000122 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
123 SSHServerIdentity []byte `json:"ssh_server_identity"`
124 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
125 SSHHostCertificate []byte `json:"ssh_host_certificate"`
126 SSHAvailable bool `json:"ssh_available"`
127 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700128}
129
Earl Lee2e463fb2025-04-17 11:22:22 -0700130// Server serves sketch HTTP. Server implements http.Handler.
131type Server struct {
132 mux *http.ServeMux
133 agent loop.CodingAgent
134 hostname string
135 logFile *os.File
136 // Mutex to protect terminalSessions
137 ptyMutex sync.Mutex
138 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000139 sshAvailable bool
140 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700141}
142
143func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000144 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
145 if port := s.ParsePortProxyHost(r.Host); port != "" {
146 s.proxyToPort(w, r, port)
147 return
148 }
149
Earl Lee2e463fb2025-04-17 11:22:22 -0700150 s.mux.ServeHTTP(w, r)
151}
152
Philip Zeyligera9710d72025-07-02 02:50:14 +0000153// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
154func (s *Server) ParsePortProxyHost(host string) string {
155 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
156 hostname := host
157 if idx := strings.LastIndex(host, ":"); idx > 0 {
158 hostname = host[:idx]
159 }
160
161 // Check if hostname matches p<port>.localhost pattern
162 if strings.HasSuffix(hostname, ".localhost") {
163 prefix := strings.TrimSuffix(hostname, ".localhost")
164 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
165 port := prefix[1:] // Remove 'p' prefix
166 // Basic validation - port should be numeric and in valid range
167 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
168 return port
169 }
170 }
171 }
172
173 return ""
174}
175
176// proxyToPort proxies the request to localhost:<port>
177func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
178 // Create a reverse proxy to localhost:<port>
179 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
180 if err != nil {
181 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
182 return
183 }
184
185 proxy := httputil.NewSingleHostReverseProxy(target)
186
187 // Customize the Director to modify the request
188 originalDirector := proxy.Director
189 proxy.Director = func(req *http.Request) {
190 originalDirector(req)
191 // Set the target host
192 req.URL.Host = target.Host
193 req.URL.Scheme = target.Scheme
194 req.Host = target.Host
195 }
196
197 // Handle proxy errors
198 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
199 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
200 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
201 }
202
203 proxy.ServeHTTP(w, r)
204}
205
Earl Lee2e463fb2025-04-17 11:22:22 -0700206// New creates a new HTTP server.
207func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
208 s := &Server{
209 mux: http.NewServeMux(),
210 agent: agent,
211 hostname: getHostname(),
212 logFile: logFile,
213 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000214 sshAvailable: false,
215 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 }
217
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000218 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000219
220 // Git tool endpoints
221 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
222 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700223 s.mux.HandleFunc("/git/cat", s.handleGitCat)
224 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000225 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000226 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000227
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
229 // Check if a specific commit hash was requested
230 commit := r.URL.Query().Get("commit")
231
232 // Get the diff, optionally for a specific commit
233 var diff string
234 var err error
235 if commit != "" {
236 // Validate the commit hash format
237 if !isValidGitSHA(commit) {
238 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
239 return
240 }
241
242 diff, err = agent.Diff(&commit)
243 } else {
244 diff, err = agent.Diff(nil)
245 }
246
247 if err != nil {
248 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
249 return
250 }
251
252 w.Header().Set("Content-Type", "text/plain")
253 w.Write([]byte(diff))
254 })
255
256 // Handler for initialization called by host sketch binary when inside docker.
257 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
258 defer func() {
259 if err := recover(); err != nil {
260 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
261
262 // Return an error response to the client
263 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
264 }
265 }()
266
267 if r.Method != "POST" {
268 http.Error(w, "POST required", http.StatusBadRequest)
269 return
270 }
271
272 body, err := io.ReadAll(r.Body)
273 r.Body.Close()
274 if err != nil {
275 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
276 return
277 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700278
279 m := &InitRequest{}
280 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700281 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
282 return
283 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700284
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000285 // Store SSH availability info
286 s.sshAvailable = m.SSHAvailable
287 s.sshError = m.SSHError
288
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700289 // Start the SSH server if the init request included ssh keys.
290 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
291 go func() {
292 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000293 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700294 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000295 // Update SSH error if server fails to start
296 s.sshAvailable = false
297 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700298 }
299 }()
300 }
301
Earl Lee2e463fb2025-04-17 11:22:22 -0700302 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700303 InDocker: true,
304 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700305 }
306 if err := agent.Init(ini); err != nil {
307 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
308 return
309 }
310 w.Header().Set("Content-Type", "application/json")
311 io.WriteString(w, "{}\n")
312 })
313
314 // Handler for /messages?start=N&end=M (start/end are optional)
315 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
316 w.Header().Set("Content-Type", "application/json")
317
318 // Extract query parameters for range
319 var start, end int
320 var err error
321
322 currentCount := agent.MessageCount()
323
324 startParam := r.URL.Query().Get("start")
325 if startParam != "" {
326 start, err = strconv.Atoi(startParam)
327 if err != nil {
328 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
329 return
330 }
331 }
332
333 endParam := r.URL.Query().Get("end")
334 if endParam != "" {
335 end, err = strconv.Atoi(endParam)
336 if err != nil {
337 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
338 return
339 }
340 } else {
341 end = currentCount
342 }
343
344 if start < 0 || start > end || end > currentCount {
345 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
346 return
347 }
348
349 start = max(0, start)
350 end = min(agent.MessageCount(), end)
351 messages := agent.Messages(start, end)
352
353 // Create a JSON encoder with indentation for pretty-printing
354 encoder := json.NewEncoder(w)
355 encoder.SetIndent("", " ") // Two spaces for each indentation level
356
357 err = encoder.Encode(messages)
358 if err != nil {
359 http.Error(w, err.Error(), http.StatusInternalServerError)
360 }
361 })
362
363 // Handler for /logs - displays the contents of the log file
364 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
365 if s.logFile == nil {
366 http.Error(w, "log file not set", http.StatusNotFound)
367 return
368 }
369 logContents, err := os.ReadFile(s.logFile.Name())
370 if err != nil {
371 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
372 return
373 }
374 w.Header().Set("Content-Type", "text/html; charset=utf-8")
375 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
376 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
377 fmt.Fprintf(w, "</body>\n</html>")
378 })
379
380 // Handler for /download - downloads both messages and status as a JSON file
381 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
382 // Set headers for file download
383 w.Header().Set("Content-Type", "application/octet-stream")
384
385 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
386 timestamp := time.Now().Format("20060102-150405")
387 filename := fmt.Sprintf("sketch-%s.json", timestamp)
388
389 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
390
391 // Get all messages
392 messageCount := agent.MessageCount()
393 messages := agent.Messages(0, messageCount)
394
395 // Get status information (usage and other metadata)
396 totalUsage := agent.TotalUsage()
397 hostname := getHostname()
398 workingDir := getWorkingDir()
399
400 // Create a combined structure with all information
401 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700402 Messages []loop.AgentMessage `json:"messages"`
403 MessageCount int `json:"message_count"`
404 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
405 Hostname string `json:"hostname"`
406 WorkingDir string `json:"working_dir"`
407 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700408 }{
409 Messages: messages,
410 MessageCount: messageCount,
411 TotalUsage: totalUsage,
412 Hostname: hostname,
413 WorkingDir: workingDir,
414 DownloadTime: time.Now().Format(time.RFC3339),
415 }
416
417 // Marshal the JSON with indentation for better readability
418 jsonData, err := json.MarshalIndent(downloadData, "", " ")
419 if err != nil {
420 http.Error(w, err.Error(), http.StatusInternalServerError)
421 return
422 }
423 w.Write(jsonData)
424 })
425
426 // The latter doesn't return until the number of messages has changed (from seen
427 // or from when this was called.)
428 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
429 pollParam := r.URL.Query().Get("poll")
430 seenParam := r.URL.Query().Get("seen")
431
432 // Get the client's current message count (if provided)
433 clientMessageCount := -1
434 var err error
435 if seenParam != "" {
436 clientMessageCount, err = strconv.Atoi(seenParam)
437 if err != nil {
438 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
439 return
440 }
441 }
442
443 serverMessageCount := agent.MessageCount()
444
445 // Let lazy clients not have to specify this.
446 if clientMessageCount == -1 {
447 clientMessageCount = serverMessageCount
448 }
449
450 if pollParam == "true" {
451 ch := make(chan string)
452 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700453 it := agent.NewIterator(r.Context(), clientMessageCount)
454 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700456 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700457 }()
458 select {
459 case <-r.Context().Done():
460 slog.DebugContext(r.Context(), "abandoned poll request")
461 return
462 case <-time.After(90 * time.Second):
463 // Let the user call /state again to get the latest to limit how long our long polls hang out.
464 slog.DebugContext(r.Context(), "longish poll request")
465 break
466 case <-ch:
467 break
468 }
469 }
470
Earl Lee2e463fb2025-04-17 11:22:22 -0700471 w.Header().Set("Content-Type", "application/json")
472
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000473 // Use the shared getState function
474 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700475
476 // Create a JSON encoder with indentation for pretty-printing
477 encoder := json.NewEncoder(w)
478 encoder.SetIndent("", " ") // Two spaces for each indentation level
479
480 err = encoder.Encode(state)
481 if err != nil {
482 http.Error(w, err.Error(), http.StatusInternalServerError)
483 }
484 })
485
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700486 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700487
488 // Terminal WebSocket handler
489 // Terminal endpoints - predefined terminals 1-9
490 // TODO: The UI doesn't actually know how to use terminals 2-9!
491 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
492 if r.Method != http.MethodGet {
493 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
494 return
495 }
496 pathParts := strings.Split(r.URL.Path, "/")
497 if len(pathParts) < 4 {
498 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
499 return
500 }
501
502 sessionID := pathParts[3]
503 // Validate that the terminal ID is between 1-9
504 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
505 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
506 return
507 }
508
509 s.handleTerminalEvents(w, r, sessionID)
510 })
511
512 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
513 if r.Method != http.MethodPost {
514 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
515 return
516 }
517 pathParts := strings.Split(r.URL.Path, "/")
518 if len(pathParts) < 4 {
519 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
520 return
521 }
522 sessionID := pathParts[3]
523 s.handleTerminalInput(w, r, sessionID)
524 })
525
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700526 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700527 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700528 webuiFS := embedded.WebUIFS()
529 appShell := "sketch-app-shell.html"
530 if r.URL.Query().Has("m") {
531 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700532 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700533 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700534 })
535
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700536 // Handler for /commit-description - returns the description of a git commit
537 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
538 if r.Method != http.MethodGet {
539 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
540 return
541 }
542
543 // Get the revision parameter
544 revision := r.URL.Query().Get("revision")
545 if revision == "" {
546 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
547 return
548 }
549
550 // Run git command to get commit description
551 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
552 // Use the working directory from the agent
553 cmd.Dir = s.agent.WorkingDir()
554
555 output, err := cmd.CombinedOutput()
556 if err != nil {
557 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
558 return
559 }
560
561 // Prepare the response
562 resp := map[string]string{
563 "description": strings.TrimSpace(string(output)),
564 }
565
566 w.Header().Set("Content-Type", "application/json")
567 if err := json.NewEncoder(w).Encode(resp); err != nil {
568 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
569 }
570 })
571
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000572 // Handler for /screenshot/{id} - serves screenshot images
573 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
574 if r.Method != http.MethodGet {
575 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
576 return
577 }
578
579 // Extract the screenshot ID from the path
580 pathParts := strings.Split(r.URL.Path, "/")
581 if len(pathParts) < 3 {
582 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
583 return
584 }
585
586 screenshotID := pathParts[2]
587
588 // Validate the ID format (prevent directory traversal)
589 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
590 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
591 return
592 }
593
594 // Get the screenshot file path
595 filePath := browse.GetScreenshotPath(screenshotID)
596
597 // Check if the file exists
598 if _, err := os.Stat(filePath); os.IsNotExist(err) {
599 http.Error(w, "Screenshot not found", http.StatusNotFound)
600 return
601 }
602
603 // Serve the file
604 w.Header().Set("Content-Type", "image/png")
605 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
606 http.ServeFile(w, r, filePath)
607 })
608
Earl Lee2e463fb2025-04-17 11:22:22 -0700609 // Handler for POST /chat
610 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
611 if r.Method != http.MethodPost {
612 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
613 return
614 }
615
616 // Parse the request body
617 var requestBody struct {
618 Message string `json:"message"`
619 }
620
621 decoder := json.NewDecoder(r.Body)
622 if err := decoder.Decode(&requestBody); err != nil {
623 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
624 return
625 }
626 defer r.Body.Close()
627
628 if requestBody.Message == "" {
629 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
630 return
631 }
632
633 agent.UserMessage(r.Context(), requestBody.Message)
634
635 w.WriteHeader(http.StatusOK)
636 })
637
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000638 // Handler for POST /upload - uploads a file to /tmp
639 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
640 if r.Method != http.MethodPost {
641 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
642 return
643 }
644
645 // Limit to 10MB file size
646 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
647
648 // Parse the multipart form
649 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
650 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
651 return
652 }
653
654 // Get the file from the multipart form
655 file, handler, err := r.FormFile("file")
656 if err != nil {
657 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
658 return
659 }
660 defer file.Close()
661
662 // Generate a unique ID (8 random bytes converted to 16 hex chars)
663 randBytes := make([]byte, 8)
664 if _, err := rand.Read(randBytes); err != nil {
665 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
666 return
667 }
668
669 // Get file extension from the original filename
670 ext := filepath.Ext(handler.Filename)
671
672 // Create a unique filename in the /tmp directory
673 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
674
675 // Create the destination file
676 destFile, err := os.Create(filename)
677 if err != nil {
678 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
679 return
680 }
681 defer destFile.Close()
682
683 // Copy the file contents to the destination file
684 if _, err := io.Copy(destFile, file); err != nil {
685 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
686 return
687 }
688
689 // Return the path to the saved file
690 w.Header().Set("Content-Type", "application/json")
691 json.NewEncoder(w).Encode(map[string]string{"path": filename})
692 })
693
Earl Lee2e463fb2025-04-17 11:22:22 -0700694 // Handler for /cancel - cancels the current inner loop in progress
695 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
696 if r.Method != http.MethodPost {
697 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
698 return
699 }
700
701 // Parse the request body (optional)
702 var requestBody struct {
703 Reason string `json:"reason"`
704 ToolCallID string `json:"tool_call_id"`
705 }
706
707 decoder := json.NewDecoder(r.Body)
708 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
709 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
710 return
711 }
712 defer r.Body.Close()
713
714 cancelReason := "user requested cancellation"
715 if requestBody.Reason != "" {
716 cancelReason = requestBody.Reason
717 }
718
719 if requestBody.ToolCallID != "" {
720 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
721 if err != nil {
722 http.Error(w, err.Error(), http.StatusBadRequest)
723 return
724 }
725 // Return a success response
726 w.Header().Set("Content-Type", "application/json")
727 json.NewEncoder(w).Encode(map[string]string{
728 "status": "cancelled",
729 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700730 "reason": cancelReason,
731 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700732 return
733 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000734 // Call the CancelTurn method
735 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700736 // Return a success response
737 w.Header().Set("Content-Type", "application/json")
738 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
739 })
740
Pokey Rule397871d2025-05-19 15:02:45 +0100741 // Handler for /end - shuts down the inner sketch process
742 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
743 if r.Method != http.MethodPost {
744 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
745 return
746 }
747
748 // Parse the request body (optional)
749 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700750 Reason string `json:"reason"`
751 Happy *bool `json:"happy,omitempty"`
752 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100753 }
754
755 decoder := json.NewDecoder(r.Body)
756 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
757 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
758 return
759 }
760 defer r.Body.Close()
761
762 endReason := "user requested end of session"
763 if requestBody.Reason != "" {
764 endReason = requestBody.Reason
765 }
766
767 // Send success response before exiting
768 w.Header().Set("Content-Type", "application/json")
769 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
770 if f, ok := w.(http.Flusher); ok {
771 f.Flush()
772 }
773
774 // Log that we're shutting down
775 slog.Info("Ending session", "reason", endReason)
776
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000777 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100778 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000779 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100780 os.Exit(0)
781 }()
782 })
783
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700784 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700785 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
786 debugMux.ServeHTTP(w, r)
787 })
788
789 return s, nil
790}
791
792// Utility functions
793func getHostname() string {
794 hostname, err := os.Hostname()
795 if err != nil {
796 return "unknown"
797 }
798 return hostname
799}
800
801func getWorkingDir() string {
802 wd, err := os.Getwd()
803 if err != nil {
804 return "unknown"
805 }
806 return wd
807}
808
809// createTerminalSession creates a new terminal session with the given ID
810func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
811 // Start a new shell process
812 shellPath := getShellPath()
813 cmd := exec.Command(shellPath)
814
815 // Get working directory from the agent if possible
816 workDir := getWorkingDir()
817 cmd.Dir = workDir
818
819 // Set up environment
820 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
821
822 // Start the command with a pty
823 ptmx, err := pty.Start(cmd)
824 if err != nil {
825 slog.Error("Failed to start pty", "error", err)
826 return nil, err
827 }
828
829 // Create the terminal session
830 session := &terminalSession{
831 pty: ptmx,
832 eventsClients: make(map[chan []byte]bool),
833 cmd: cmd,
834 }
835
836 // Start goroutine to read from pty and broadcast to all connected SSE clients
837 go s.readFromPtyAndBroadcast(sessionID, session)
838
839 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000840}
841
842// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700843func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
844 // Check if the session exists, if not, create it
845 s.ptyMutex.Lock()
846 session, exists := s.terminalSessions[sessionID]
847
848 if !exists {
849 // Create a new terminal session
850 var err error
851 session, err = s.createTerminalSession(sessionID)
852 if err != nil {
853 s.ptyMutex.Unlock()
854 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
855 return
856 }
857
858 // Store the new session
859 s.terminalSessions[sessionID] = session
860 }
861 s.ptyMutex.Unlock()
862
863 // Set headers for SSE
864 w.Header().Set("Content-Type", "text/event-stream")
865 w.Header().Set("Cache-Control", "no-cache")
866 w.Header().Set("Connection", "keep-alive")
867 w.Header().Set("Access-Control-Allow-Origin", "*")
868
869 // Create a channel for this client
870 events := make(chan []byte, 4096) // Buffer to prevent blocking
871
872 // Register this client's channel
873 session.eventsClientsMutex.Lock()
874 clientID := session.lastEventClientID + 1
875 session.lastEventClientID = clientID
876 session.eventsClients[events] = true
877 session.eventsClientsMutex.Unlock()
878
879 // When the client disconnects, remove their channel
880 defer func() {
881 session.eventsClientsMutex.Lock()
882 delete(session.eventsClients, events)
883 close(events)
884 session.eventsClientsMutex.Unlock()
885 }()
886
887 // Flush to send headers to client immediately
888 if f, ok := w.(http.Flusher); ok {
889 f.Flush()
890 }
891
892 // Send events to the client as they arrive
893 for {
894 select {
895 case <-r.Context().Done():
896 return
897 case data := <-events:
898 // Format as SSE with base64 encoding
899 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
900
901 // Flush the data immediately
902 if f, ok := w.(http.Flusher); ok {
903 f.Flush()
904 }
905 }
906 }
907}
908
909// handleTerminalInput processes input to the terminal
910func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
911 // Check if the session exists
912 s.ptyMutex.Lock()
913 session, exists := s.terminalSessions[sessionID]
914 s.ptyMutex.Unlock()
915
916 if !exists {
917 http.Error(w, "Terminal session not found", http.StatusNotFound)
918 return
919 }
920
921 // Read the request body (terminal input or resize command)
922 body, err := io.ReadAll(r.Body)
923 if err != nil {
924 http.Error(w, "Failed to read request body", http.StatusBadRequest)
925 return
926 }
927
928 // Check if it's a resize message
929 if len(body) > 0 && body[0] == '{' {
930 var msg TerminalMessage
931 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
932 if msg.Cols > 0 && msg.Rows > 0 {
933 pty.Setsize(session.pty, &pty.Winsize{
934 Cols: msg.Cols,
935 Rows: msg.Rows,
936 })
937
938 // Respond with success
939 w.WriteHeader(http.StatusOK)
940 return
941 }
942 }
943 }
944
945 // Regular terminal input
946 _, err = session.pty.Write(body)
947 if err != nil {
948 slog.Error("Failed to write to pty", "error", err)
949 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
950 return
951 }
952
953 // Respond with success
954 w.WriteHeader(http.StatusOK)
955}
956
957// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
958func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
959 buf := make([]byte, 4096)
960 defer func() {
961 // Clean up when done
962 s.ptyMutex.Lock()
963 delete(s.terminalSessions, sessionID)
964 s.ptyMutex.Unlock()
965
966 // Close the PTY
967 session.pty.Close()
968
969 // Ensure process is terminated
970 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700971 session.cmd.Process.Kill()
972 }
David Crawshawb8431462025-07-09 13:10:32 +1000973 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700974
975 // Close all client channels
976 session.eventsClientsMutex.Lock()
977 for ch := range session.eventsClients {
978 delete(session.eventsClients, ch)
979 close(ch)
980 }
981 session.eventsClientsMutex.Unlock()
982 }()
983
984 for {
985 n, err := session.pty.Read(buf)
986 if err != nil {
987 if err != io.EOF {
988 slog.Error("Failed to read from pty", "error", err)
989 }
990 break
991 }
992
993 // Make a copy of the data for each client
994 data := make([]byte, n)
995 copy(data, buf[:n])
996
997 // Broadcast to all connected clients
998 session.eventsClientsMutex.Lock()
999 for ch := range session.eventsClients {
1000 // Try to send, but don't block if channel is full
1001 select {
1002 case ch <- data:
1003 default:
1004 // Channel is full, drop the message for this client
1005 }
1006 }
1007 session.eventsClientsMutex.Unlock()
1008 }
1009}
1010
1011// getShellPath returns the path to the shell to use
1012func getShellPath() string {
1013 // Try to use the user's preferred shell
1014 shell := os.Getenv("SHELL")
1015 if shell != "" {
1016 return shell
1017 }
1018
1019 // Default to bash on Unix-like systems
1020 if _, err := os.Stat("/bin/bash"); err == nil {
1021 return "/bin/bash"
1022 }
1023
1024 // Fall back to sh
1025 return "/bin/sh"
1026}
1027
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001028func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001029 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001030 build := "unknown build"
1031 bi, ok := debug.ReadBuildInfo()
1032 if ok {
1033 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1034 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1036 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001037 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 fmt.Fprintf(w, `<!doctype html>
1039 <html><head><title>sketch debug</title></head><body>
1040 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001041 pid %d<br>
1042 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001044 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1045 <li><a href="pprof/profile">pprof/profile</a></li>
1046 <li><a href="pprof/symbol">pprof/symbol</a></li>
1047 <li><a href="pprof/trace">pprof/trace</a></li>
1048 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001049 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 </ul>
1051 </body>
1052 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001053 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 })
1055 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1056 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1057 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1058 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1059 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001060
1061 // Add conversation history debug handler
1062 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1063 w.Header().Set("Content-Type", "application/json")
1064
1065 // Use type assertion to access the GetConvo method
1066 type ConvoProvider interface {
1067 GetConvo() loop.ConvoInterface
1068 }
1069
1070 if convoProvider, ok := agent.(ConvoProvider); ok {
1071 // Call the DebugJSON method to get the conversation history
1072 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1073 if err != nil {
1074 http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
1075 return
1076 }
1077
1078 // Write the JSON response
1079 w.Write(historyJSON)
1080 } else {
1081 http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
1082 }
1083 })
1084
Earl Lee2e463fb2025-04-17 11:22:22 -07001085 return mux
1086}
1087
1088// isValidGitSHA validates if a string looks like a valid git SHA hash.
1089// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1090func isValidGitSHA(sha string) bool {
1091 // Git SHA must be a hexadecimal string with at least 4 characters
1092 if len(sha) < 4 || len(sha) > 40 {
1093 return false
1094 }
1095
1096 // Check if the string only contains hexadecimal characters
1097 for _, char := range sha {
1098 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1099 return false
1100 }
1101 }
1102
1103 return true
1104}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001105
1106// /stream?from=N endpoint for Server-Sent Events
1107func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1108 w.Header().Set("Content-Type", "text/event-stream")
1109 w.Header().Set("Cache-Control", "no-cache")
1110 w.Header().Set("Connection", "keep-alive")
1111 w.Header().Set("Access-Control-Allow-Origin", "*")
1112
1113 // Extract the 'from' parameter
1114 fromParam := r.URL.Query().Get("from")
1115 var fromIndex int
1116 var err error
1117 if fromParam != "" {
1118 fromIndex, err = strconv.Atoi(fromParam)
1119 if err != nil {
1120 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1121 return
1122 }
1123 }
1124
1125 // Ensure 'from' is valid
1126 currentCount := s.agent.MessageCount()
1127 if fromIndex < 0 {
1128 fromIndex = 0
1129 } else if fromIndex > currentCount {
1130 fromIndex = currentCount
1131 }
1132
1133 // Send the current state immediately
1134 state := s.getState()
1135
1136 // Create JSON encoder
1137 encoder := json.NewEncoder(w)
1138
1139 // Send state as an event
1140 fmt.Fprintf(w, "event: state\n")
1141 fmt.Fprintf(w, "data: ")
1142 encoder.Encode(state)
1143 fmt.Fprintf(w, "\n\n")
1144
1145 if f, ok := w.(http.Flusher); ok {
1146 f.Flush()
1147 }
1148
1149 // Create a context for the SSE stream
1150 ctx := r.Context()
1151
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001152 // Setup heartbeat timer
1153 heartbeatTicker := time.NewTicker(45 * time.Second)
1154 defer heartbeatTicker.Stop()
1155
1156 // Create a channel for messages
1157 messageChan := make(chan *loop.AgentMessage, 10)
1158
Philip Zeyligereab12de2025-05-14 02:35:53 +00001159 // Create a channel for state transitions
1160 stateChan := make(chan *loop.StateTransition, 10)
1161
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001162 // Start a goroutine to read messages without blocking the heartbeat
1163 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001164 // Create an iterator to receive new messages as they arrive
1165 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1166 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001167 defer close(messageChan)
1168 for {
1169 // This can block, but it's in its own goroutine
1170 newMessage := iterator.Next()
1171 if newMessage == nil {
1172 // No message available (likely due to context cancellation)
1173 slog.InfoContext(ctx, "No more messages available, ending message stream")
1174 return
1175 }
1176
1177 select {
1178 case messageChan <- newMessage:
1179 // Message sent to channel
1180 case <-ctx.Done():
1181 // Context cancelled
1182 return
1183 }
1184 }
1185 }()
1186
Philip Zeyligereab12de2025-05-14 02:35:53 +00001187 // Start a goroutine to read state transitions
1188 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001189 // Create an iterator to receive state transitions
1190 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1191 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001192 defer close(stateChan)
1193 for {
1194 // This can block, but it's in its own goroutine
1195 newTransition := stateIterator.Next()
1196 if newTransition == nil {
1197 // No transition available (likely due to context cancellation)
1198 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1199 return
1200 }
1201
1202 select {
1203 case stateChan <- newTransition:
1204 // Transition sent to channel
1205 case <-ctx.Done():
1206 // Context cancelled
1207 return
1208 }
1209 }
1210 }()
1211
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001212 // Stay connected and stream real-time updates
1213 for {
1214 select {
1215 case <-heartbeatTicker.C:
1216 // Send heartbeat event
1217 fmt.Fprintf(w, "event: heartbeat\n")
1218 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1219
1220 // Flush to send the heartbeat immediately
1221 if f, ok := w.(http.Flusher); ok {
1222 f.Flush()
1223 }
1224
1225 case <-ctx.Done():
1226 // Client disconnected
1227 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1228 return
1229
Philip Zeyligereab12de2025-05-14 02:35:53 +00001230 case _, ok := <-stateChan:
1231 if !ok {
1232 // Channel closed
1233 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1234 return
1235 }
1236
1237 // Get updated state
1238 state = s.getState()
1239
1240 // Send updated state after the state transition
1241 fmt.Fprintf(w, "event: state\n")
1242 fmt.Fprintf(w, "data: ")
1243 encoder.Encode(state)
1244 fmt.Fprintf(w, "\n\n")
1245
1246 // Flush to send the state immediately
1247 if f, ok := w.(http.Flusher); ok {
1248 f.Flush()
1249 }
1250
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001251 case newMessage, ok := <-messageChan:
1252 if !ok {
1253 // Channel closed
1254 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1255 return
1256 }
1257
1258 // Send the new message as an event
1259 fmt.Fprintf(w, "event: message\n")
1260 fmt.Fprintf(w, "data: ")
1261 encoder.Encode(newMessage)
1262 fmt.Fprintf(w, "\n\n")
1263
1264 // Get updated state
1265 state = s.getState()
1266
1267 // Send updated state after the message
1268 fmt.Fprintf(w, "event: state\n")
1269 fmt.Fprintf(w, "data: ")
1270 encoder.Encode(state)
1271 fmt.Fprintf(w, "\n\n")
1272
1273 // Flush to send the message and state immediately
1274 if f, ok := w.(http.Flusher); ok {
1275 f.Flush()
1276 }
1277 }
1278 }
1279}
1280
1281// Helper function to get the current state
1282func (s *Server) getState() State {
1283 serverMessageCount := s.agent.MessageCount()
1284 totalUsage := s.agent.TotalUsage()
1285
Philip Zeyliger64f60462025-06-16 13:57:10 -07001286 // Get diff stats
1287 diffAdded, diffRemoved := s.agent.DiffStats()
1288
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001289 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001290 StateVersion: 2,
1291 MessageCount: serverMessageCount,
1292 TotalUsage: &totalUsage,
1293 Hostname: s.hostname,
1294 WorkingDir: getWorkingDir(),
1295 // TODO: Rename this field to sketch-base?
1296 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001297 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001298 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001299 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001300 OS: s.agent.OS(),
1301 OutsideHostname: s.agent.OutsideHostname(),
1302 InsideHostname: s.hostname,
1303 OutsideOS: s.agent.OutsideOS(),
1304 InsideOS: s.agent.OS(),
1305 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1306 InsideWorkingDir: getWorkingDir(),
1307 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001308 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001309 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1310 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1311 SessionID: s.agent.SessionID(),
1312 SSHAvailable: s.sshAvailable,
1313 SSHError: s.sshError,
1314 InContainer: s.agent.IsInContainer(),
1315 FirstMessageIndex: s.agent.FirstMessageIndex(),
1316 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001317 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001318 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001319 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001320 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001321 DiffLinesAdded: diffAdded,
1322 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001323 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001324 TokenContextWindow: s.agent.TokenContextWindow(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001325 }
1326}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001327
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001328// getOpenPorts retrieves the current open ports from the agent
1329func (s *Server) getOpenPorts() []Port {
1330 ports := s.agent.GetPorts()
1331 if ports == nil {
1332 return nil
1333 }
1334
1335 result := make([]Port, len(ports))
1336 for i, port := range ports {
1337 result[i] = Port{
1338 Proto: port.Proto,
1339 Port: port.Port,
1340 Process: port.Process,
1341 Pid: port.Pid,
1342 }
1343 }
1344 return result
1345}
1346
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001347func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1348 if r.Method != "GET" {
1349 w.WriteHeader(http.StatusMethodNotAllowed)
1350 return
1351 }
1352
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001353 // Get the git repository root directory from agent
1354 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001355
1356 // Parse query parameters
1357 query := r.URL.Query()
1358 commit := query.Get("commit")
1359 from := query.Get("from")
1360 to := query.Get("to")
1361
1362 // If commit is specified, use commit^ and commit as from and to
1363 if commit != "" {
1364 from = commit + "^"
1365 to = commit
1366 }
1367
1368 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001369 if from == "" {
1370 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001371 return
1372 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001373 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001374
1375 // Call the git_tools function
1376 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1377 if err != nil {
1378 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1379 return
1380 }
1381
1382 // Return the result as JSON
1383 w.Header().Set("Content-Type", "application/json")
1384 if err := json.NewEncoder(w).Encode(diff); err != nil {
1385 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1386 return
1387 }
1388}
1389
1390func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1391 if r.Method != "GET" {
1392 w.WriteHeader(http.StatusMethodNotAllowed)
1393 return
1394 }
1395
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001396 // Get the git repository root directory from agent
1397 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001398
1399 // Parse query parameters
1400 hash := r.URL.Query().Get("hash")
1401 if hash == "" {
1402 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1403 return
1404 }
1405
1406 // Call the git_tools function
1407 show, err := git_tools.GitShow(repoDir, hash)
1408 if err != nil {
1409 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1410 return
1411 }
1412
1413 // Create a JSON response
1414 response := map[string]string{
1415 "hash": hash,
1416 "output": show,
1417 }
1418
1419 // Return the result as JSON
1420 w.Header().Set("Content-Type", "application/json")
1421 if err := json.NewEncoder(w).Encode(response); err != nil {
1422 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1423 return
1424 }
1425}
1426
1427func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1428 if r.Method != "GET" {
1429 w.WriteHeader(http.StatusMethodNotAllowed)
1430 return
1431 }
1432
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001433 // Get the git repository root directory and initial commit from agent
1434 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001435 initialCommit := s.agent.SketchGitBaseRef()
1436
1437 // Call the git_tools function
1438 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1439 if err != nil {
1440 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1441 return
1442 }
1443
1444 // Return the result as JSON
1445 w.Header().Set("Content-Type", "application/json")
1446 if err := json.NewEncoder(w).Encode(log); err != nil {
1447 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1448 return
1449 }
1450}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001451
1452func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1453 if r.Method != "GET" {
1454 w.WriteHeader(http.StatusMethodNotAllowed)
1455 return
1456 }
1457
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001458 // Get the git repository root directory from agent
1459 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001460
1461 // Parse query parameters
1462 query := r.URL.Query()
1463 path := query.Get("path")
1464
1465 // Check if path is provided
1466 if path == "" {
1467 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1468 return
1469 }
1470
1471 // Get file content using GitCat
1472 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001473 switch {
1474 case err == nil:
1475 // continued below
1476 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001477 w.WriteHeader(http.StatusNoContent)
1478 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001479 default:
1480 http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001481 return
1482 }
1483
1484 // Return the content as JSON for consistency with other endpoints
1485 w.Header().Set("Content-Type", "application/json")
1486 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1487 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1488 return
1489 }
1490}
1491
1492func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1493 if r.Method != "POST" {
1494 w.WriteHeader(http.StatusMethodNotAllowed)
1495 return
1496 }
1497
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001498 // Get the git repository root directory from agent
1499 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001500
1501 // Parse request body
1502 var requestBody struct {
1503 Path string `json:"path"`
1504 Content string `json:"content"`
1505 }
1506
1507 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1508 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1509 return
1510 }
1511 defer r.Body.Close()
1512
1513 // Check if path is provided
1514 if requestBody.Path == "" {
1515 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1516 return
1517 }
1518
1519 // Save file content using GitSaveFile
1520 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1521 if err != nil {
1522 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1523 return
1524 }
1525
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001526 // Auto-commit the changes
1527 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1528 if err != nil {
1529 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1530 return
1531 }
1532
1533 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001534 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1535 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1536 return
1537 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001538
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001539 // Return simple success response
1540 w.WriteHeader(http.StatusOK)
1541 w.Write([]byte("ok"))
1542}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001543
1544func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1545 if r.Method != "GET" {
1546 w.WriteHeader(http.StatusMethodNotAllowed)
1547 return
1548 }
1549
1550 repoDir := s.agent.RepoRoot()
1551 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1552 if err != nil {
1553 http.Error(w, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
1554 return
1555 }
1556
1557 w.Header().Set("Content-Type", "application/json")
1558 response := map[string][]string{
1559 "untracked_files": untrackedFiles,
1560 }
1561 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1562}