blob: 7e06214511032fb0d5b8c6b7af50e14fc13574c9 [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)
226
Earl Lee2e463fb2025-04-17 11:22:22 -0700227 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
228 // Check if a specific commit hash was requested
229 commit := r.URL.Query().Get("commit")
230
231 // Get the diff, optionally for a specific commit
232 var diff string
233 var err error
234 if commit != "" {
235 // Validate the commit hash format
236 if !isValidGitSHA(commit) {
237 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
238 return
239 }
240
241 diff, err = agent.Diff(&commit)
242 } else {
243 diff, err = agent.Diff(nil)
244 }
245
246 if err != nil {
247 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
248 return
249 }
250
251 w.Header().Set("Content-Type", "text/plain")
252 w.Write([]byte(diff))
253 })
254
255 // Handler for initialization called by host sketch binary when inside docker.
256 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
257 defer func() {
258 if err := recover(); err != nil {
259 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
260
261 // Return an error response to the client
262 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
263 }
264 }()
265
266 if r.Method != "POST" {
267 http.Error(w, "POST required", http.StatusBadRequest)
268 return
269 }
270
271 body, err := io.ReadAll(r.Body)
272 r.Body.Close()
273 if err != nil {
274 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
275 return
276 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700277
278 m := &InitRequest{}
279 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
281 return
282 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700283
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000284 // Store SSH availability info
285 s.sshAvailable = m.SSHAvailable
286 s.sshError = m.SSHError
287
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700288 // Start the SSH server if the init request included ssh keys.
289 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
290 go func() {
291 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000292 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700293 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000294 // Update SSH error if server fails to start
295 s.sshAvailable = false
296 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700297 }
298 }()
299 }
300
Earl Lee2e463fb2025-04-17 11:22:22 -0700301 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700302 InDocker: true,
303 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700304 }
305 if err := agent.Init(ini); err != nil {
306 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
307 return
308 }
309 w.Header().Set("Content-Type", "application/json")
310 io.WriteString(w, "{}\n")
311 })
312
313 // Handler for /messages?start=N&end=M (start/end are optional)
314 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
315 w.Header().Set("Content-Type", "application/json")
316
317 // Extract query parameters for range
318 var start, end int
319 var err error
320
321 currentCount := agent.MessageCount()
322
323 startParam := r.URL.Query().Get("start")
324 if startParam != "" {
325 start, err = strconv.Atoi(startParam)
326 if err != nil {
327 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
328 return
329 }
330 }
331
332 endParam := r.URL.Query().Get("end")
333 if endParam != "" {
334 end, err = strconv.Atoi(endParam)
335 if err != nil {
336 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
337 return
338 }
339 } else {
340 end = currentCount
341 }
342
343 if start < 0 || start > end || end > currentCount {
344 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
345 return
346 }
347
348 start = max(0, start)
349 end = min(agent.MessageCount(), end)
350 messages := agent.Messages(start, end)
351
352 // Create a JSON encoder with indentation for pretty-printing
353 encoder := json.NewEncoder(w)
354 encoder.SetIndent("", " ") // Two spaces for each indentation level
355
356 err = encoder.Encode(messages)
357 if err != nil {
358 http.Error(w, err.Error(), http.StatusInternalServerError)
359 }
360 })
361
362 // Handler for /logs - displays the contents of the log file
363 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
364 if s.logFile == nil {
365 http.Error(w, "log file not set", http.StatusNotFound)
366 return
367 }
368 logContents, err := os.ReadFile(s.logFile.Name())
369 if err != nil {
370 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
371 return
372 }
373 w.Header().Set("Content-Type", "text/html; charset=utf-8")
374 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
375 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
376 fmt.Fprintf(w, "</body>\n</html>")
377 })
378
379 // Handler for /download - downloads both messages and status as a JSON file
380 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
381 // Set headers for file download
382 w.Header().Set("Content-Type", "application/octet-stream")
383
384 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
385 timestamp := time.Now().Format("20060102-150405")
386 filename := fmt.Sprintf("sketch-%s.json", timestamp)
387
388 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
389
390 // Get all messages
391 messageCount := agent.MessageCount()
392 messages := agent.Messages(0, messageCount)
393
394 // Get status information (usage and other metadata)
395 totalUsage := agent.TotalUsage()
396 hostname := getHostname()
397 workingDir := getWorkingDir()
398
399 // Create a combined structure with all information
400 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700401 Messages []loop.AgentMessage `json:"messages"`
402 MessageCount int `json:"message_count"`
403 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
404 Hostname string `json:"hostname"`
405 WorkingDir string `json:"working_dir"`
406 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700407 }{
408 Messages: messages,
409 MessageCount: messageCount,
410 TotalUsage: totalUsage,
411 Hostname: hostname,
412 WorkingDir: workingDir,
413 DownloadTime: time.Now().Format(time.RFC3339),
414 }
415
416 // Marshal the JSON with indentation for better readability
417 jsonData, err := json.MarshalIndent(downloadData, "", " ")
418 if err != nil {
419 http.Error(w, err.Error(), http.StatusInternalServerError)
420 return
421 }
422 w.Write(jsonData)
423 })
424
425 // The latter doesn't return until the number of messages has changed (from seen
426 // or from when this was called.)
427 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
428 pollParam := r.URL.Query().Get("poll")
429 seenParam := r.URL.Query().Get("seen")
430
431 // Get the client's current message count (if provided)
432 clientMessageCount := -1
433 var err error
434 if seenParam != "" {
435 clientMessageCount, err = strconv.Atoi(seenParam)
436 if err != nil {
437 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
438 return
439 }
440 }
441
442 serverMessageCount := agent.MessageCount()
443
444 // Let lazy clients not have to specify this.
445 if clientMessageCount == -1 {
446 clientMessageCount = serverMessageCount
447 }
448
449 if pollParam == "true" {
450 ch := make(chan string)
451 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700452 it := agent.NewIterator(r.Context(), clientMessageCount)
453 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700454 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700455 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700456 }()
457 select {
458 case <-r.Context().Done():
459 slog.DebugContext(r.Context(), "abandoned poll request")
460 return
461 case <-time.After(90 * time.Second):
462 // Let the user call /state again to get the latest to limit how long our long polls hang out.
463 slog.DebugContext(r.Context(), "longish poll request")
464 break
465 case <-ch:
466 break
467 }
468 }
469
Earl Lee2e463fb2025-04-17 11:22:22 -0700470 w.Header().Set("Content-Type", "application/json")
471
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000472 // Use the shared getState function
473 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700474
475 // Create a JSON encoder with indentation for pretty-printing
476 encoder := json.NewEncoder(w)
477 encoder.SetIndent("", " ") // Two spaces for each indentation level
478
479 err = encoder.Encode(state)
480 if err != nil {
481 http.Error(w, err.Error(), http.StatusInternalServerError)
482 }
483 })
484
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700485 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700486
487 // Terminal WebSocket handler
488 // Terminal endpoints - predefined terminals 1-9
489 // TODO: The UI doesn't actually know how to use terminals 2-9!
490 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
491 if r.Method != http.MethodGet {
492 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
493 return
494 }
495 pathParts := strings.Split(r.URL.Path, "/")
496 if len(pathParts) < 4 {
497 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
498 return
499 }
500
501 sessionID := pathParts[3]
502 // Validate that the terminal ID is between 1-9
503 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
504 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
505 return
506 }
507
508 s.handleTerminalEvents(w, r, sessionID)
509 })
510
511 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
512 if r.Method != http.MethodPost {
513 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
514 return
515 }
516 pathParts := strings.Split(r.URL.Path, "/")
517 if len(pathParts) < 4 {
518 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
519 return
520 }
521 sessionID := pathParts[3]
522 s.handleTerminalInput(w, r, sessionID)
523 })
524
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700525 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700526 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700527 webuiFS := embedded.WebUIFS()
528 appShell := "sketch-app-shell.html"
529 if r.URL.Query().Has("m") {
530 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700531 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700532 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700533 })
534
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700535 // Handler for /commit-description - returns the description of a git commit
536 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
537 if r.Method != http.MethodGet {
538 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
539 return
540 }
541
542 // Get the revision parameter
543 revision := r.URL.Query().Get("revision")
544 if revision == "" {
545 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
546 return
547 }
548
549 // Run git command to get commit description
550 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
551 // Use the working directory from the agent
552 cmd.Dir = s.agent.WorkingDir()
553
554 output, err := cmd.CombinedOutput()
555 if err != nil {
556 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
557 return
558 }
559
560 // Prepare the response
561 resp := map[string]string{
562 "description": strings.TrimSpace(string(output)),
563 }
564
565 w.Header().Set("Content-Type", "application/json")
566 if err := json.NewEncoder(w).Encode(resp); err != nil {
567 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
568 }
569 })
570
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000571 // Handler for /screenshot/{id} - serves screenshot images
572 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
573 if r.Method != http.MethodGet {
574 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
575 return
576 }
577
578 // Extract the screenshot ID from the path
579 pathParts := strings.Split(r.URL.Path, "/")
580 if len(pathParts) < 3 {
581 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
582 return
583 }
584
585 screenshotID := pathParts[2]
586
587 // Validate the ID format (prevent directory traversal)
588 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
589 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
590 return
591 }
592
593 // Get the screenshot file path
594 filePath := browse.GetScreenshotPath(screenshotID)
595
596 // Check if the file exists
597 if _, err := os.Stat(filePath); os.IsNotExist(err) {
598 http.Error(w, "Screenshot not found", http.StatusNotFound)
599 return
600 }
601
602 // Serve the file
603 w.Header().Set("Content-Type", "image/png")
604 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
605 http.ServeFile(w, r, filePath)
606 })
607
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 // Handler for POST /chat
609 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
610 if r.Method != http.MethodPost {
611 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
612 return
613 }
614
615 // Parse the request body
616 var requestBody struct {
617 Message string `json:"message"`
618 }
619
620 decoder := json.NewDecoder(r.Body)
621 if err := decoder.Decode(&requestBody); err != nil {
622 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
623 return
624 }
625 defer r.Body.Close()
626
627 if requestBody.Message == "" {
628 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
629 return
630 }
631
632 agent.UserMessage(r.Context(), requestBody.Message)
633
634 w.WriteHeader(http.StatusOK)
635 })
636
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000637 // Handler for POST /upload - uploads a file to /tmp
638 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
639 if r.Method != http.MethodPost {
640 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
641 return
642 }
643
644 // Limit to 10MB file size
645 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
646
647 // Parse the multipart form
648 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
649 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
650 return
651 }
652
653 // Get the file from the multipart form
654 file, handler, err := r.FormFile("file")
655 if err != nil {
656 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
657 return
658 }
659 defer file.Close()
660
661 // Generate a unique ID (8 random bytes converted to 16 hex chars)
662 randBytes := make([]byte, 8)
663 if _, err := rand.Read(randBytes); err != nil {
664 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
665 return
666 }
667
668 // Get file extension from the original filename
669 ext := filepath.Ext(handler.Filename)
670
671 // Create a unique filename in the /tmp directory
672 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
673
674 // Create the destination file
675 destFile, err := os.Create(filename)
676 if err != nil {
677 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
678 return
679 }
680 defer destFile.Close()
681
682 // Copy the file contents to the destination file
683 if _, err := io.Copy(destFile, file); err != nil {
684 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
685 return
686 }
687
688 // Return the path to the saved file
689 w.Header().Set("Content-Type", "application/json")
690 json.NewEncoder(w).Encode(map[string]string{"path": filename})
691 })
692
Earl Lee2e463fb2025-04-17 11:22:22 -0700693 // Handler for /cancel - cancels the current inner loop in progress
694 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
695 if r.Method != http.MethodPost {
696 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
697 return
698 }
699
700 // Parse the request body (optional)
701 var requestBody struct {
702 Reason string `json:"reason"`
703 ToolCallID string `json:"tool_call_id"`
704 }
705
706 decoder := json.NewDecoder(r.Body)
707 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
708 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
709 return
710 }
711 defer r.Body.Close()
712
713 cancelReason := "user requested cancellation"
714 if requestBody.Reason != "" {
715 cancelReason = requestBody.Reason
716 }
717
718 if requestBody.ToolCallID != "" {
719 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
720 if err != nil {
721 http.Error(w, err.Error(), http.StatusBadRequest)
722 return
723 }
724 // Return a success response
725 w.Header().Set("Content-Type", "application/json")
726 json.NewEncoder(w).Encode(map[string]string{
727 "status": "cancelled",
728 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700729 "reason": cancelReason,
730 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700731 return
732 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000733 // Call the CancelTurn method
734 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700735 // Return a success response
736 w.Header().Set("Content-Type", "application/json")
737 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
738 })
739
Pokey Rule397871d2025-05-19 15:02:45 +0100740 // Handler for /end - shuts down the inner sketch process
741 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
742 if r.Method != http.MethodPost {
743 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
744 return
745 }
746
747 // Parse the request body (optional)
748 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700749 Reason string `json:"reason"`
750 Happy *bool `json:"happy,omitempty"`
751 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100752 }
753
754 decoder := json.NewDecoder(r.Body)
755 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
756 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
757 return
758 }
759 defer r.Body.Close()
760
761 endReason := "user requested end of session"
762 if requestBody.Reason != "" {
763 endReason = requestBody.Reason
764 }
765
766 // Send success response before exiting
767 w.Header().Set("Content-Type", "application/json")
768 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
769 if f, ok := w.(http.Flusher); ok {
770 f.Flush()
771 }
772
773 // Log that we're shutting down
774 slog.Info("Ending session", "reason", endReason)
775
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000776 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100777 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000778 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100779 os.Exit(0)
780 }()
781 })
782
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700783 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
785 debugMux.ServeHTTP(w, r)
786 })
787
788 return s, nil
789}
790
791// Utility functions
792func getHostname() string {
793 hostname, err := os.Hostname()
794 if err != nil {
795 return "unknown"
796 }
797 return hostname
798}
799
800func getWorkingDir() string {
801 wd, err := os.Getwd()
802 if err != nil {
803 return "unknown"
804 }
805 return wd
806}
807
808// createTerminalSession creates a new terminal session with the given ID
809func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
810 // Start a new shell process
811 shellPath := getShellPath()
812 cmd := exec.Command(shellPath)
813
814 // Get working directory from the agent if possible
815 workDir := getWorkingDir()
816 cmd.Dir = workDir
817
818 // Set up environment
819 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
820
821 // Start the command with a pty
822 ptmx, err := pty.Start(cmd)
823 if err != nil {
824 slog.Error("Failed to start pty", "error", err)
825 return nil, err
826 }
827
828 // Create the terminal session
829 session := &terminalSession{
830 pty: ptmx,
831 eventsClients: make(map[chan []byte]bool),
832 cmd: cmd,
833 }
834
835 // Start goroutine to read from pty and broadcast to all connected SSE clients
836 go s.readFromPtyAndBroadcast(sessionID, session)
837
838 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000839}
840
841// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700842func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
843 // Check if the session exists, if not, create it
844 s.ptyMutex.Lock()
845 session, exists := s.terminalSessions[sessionID]
846
847 if !exists {
848 // Create a new terminal session
849 var err error
850 session, err = s.createTerminalSession(sessionID)
851 if err != nil {
852 s.ptyMutex.Unlock()
853 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
854 return
855 }
856
857 // Store the new session
858 s.terminalSessions[sessionID] = session
859 }
860 s.ptyMutex.Unlock()
861
862 // Set headers for SSE
863 w.Header().Set("Content-Type", "text/event-stream")
864 w.Header().Set("Cache-Control", "no-cache")
865 w.Header().Set("Connection", "keep-alive")
866 w.Header().Set("Access-Control-Allow-Origin", "*")
867
868 // Create a channel for this client
869 events := make(chan []byte, 4096) // Buffer to prevent blocking
870
871 // Register this client's channel
872 session.eventsClientsMutex.Lock()
873 clientID := session.lastEventClientID + 1
874 session.lastEventClientID = clientID
875 session.eventsClients[events] = true
876 session.eventsClientsMutex.Unlock()
877
878 // When the client disconnects, remove their channel
879 defer func() {
880 session.eventsClientsMutex.Lock()
881 delete(session.eventsClients, events)
882 close(events)
883 session.eventsClientsMutex.Unlock()
884 }()
885
886 // Flush to send headers to client immediately
887 if f, ok := w.(http.Flusher); ok {
888 f.Flush()
889 }
890
891 // Send events to the client as they arrive
892 for {
893 select {
894 case <-r.Context().Done():
895 return
896 case data := <-events:
897 // Format as SSE with base64 encoding
898 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
899
900 // Flush the data immediately
901 if f, ok := w.(http.Flusher); ok {
902 f.Flush()
903 }
904 }
905 }
906}
907
908// handleTerminalInput processes input to the terminal
909func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
910 // Check if the session exists
911 s.ptyMutex.Lock()
912 session, exists := s.terminalSessions[sessionID]
913 s.ptyMutex.Unlock()
914
915 if !exists {
916 http.Error(w, "Terminal session not found", http.StatusNotFound)
917 return
918 }
919
920 // Read the request body (terminal input or resize command)
921 body, err := io.ReadAll(r.Body)
922 if err != nil {
923 http.Error(w, "Failed to read request body", http.StatusBadRequest)
924 return
925 }
926
927 // Check if it's a resize message
928 if len(body) > 0 && body[0] == '{' {
929 var msg TerminalMessage
930 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
931 if msg.Cols > 0 && msg.Rows > 0 {
932 pty.Setsize(session.pty, &pty.Winsize{
933 Cols: msg.Cols,
934 Rows: msg.Rows,
935 })
936
937 // Respond with success
938 w.WriteHeader(http.StatusOK)
939 return
940 }
941 }
942 }
943
944 // Regular terminal input
945 _, err = session.pty.Write(body)
946 if err != nil {
947 slog.Error("Failed to write to pty", "error", err)
948 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
949 return
950 }
951
952 // Respond with success
953 w.WriteHeader(http.StatusOK)
954}
955
956// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
957func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
958 buf := make([]byte, 4096)
959 defer func() {
960 // Clean up when done
961 s.ptyMutex.Lock()
962 delete(s.terminalSessions, sessionID)
963 s.ptyMutex.Unlock()
964
965 // Close the PTY
966 session.pty.Close()
967
968 // Ensure process is terminated
969 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 session.cmd.Process.Kill()
971 }
David Crawshawb8431462025-07-09 13:10:32 +1000972 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700973
974 // Close all client channels
975 session.eventsClientsMutex.Lock()
976 for ch := range session.eventsClients {
977 delete(session.eventsClients, ch)
978 close(ch)
979 }
980 session.eventsClientsMutex.Unlock()
981 }()
982
983 for {
984 n, err := session.pty.Read(buf)
985 if err != nil {
986 if err != io.EOF {
987 slog.Error("Failed to read from pty", "error", err)
988 }
989 break
990 }
991
992 // Make a copy of the data for each client
993 data := make([]byte, n)
994 copy(data, buf[:n])
995
996 // Broadcast to all connected clients
997 session.eventsClientsMutex.Lock()
998 for ch := range session.eventsClients {
999 // Try to send, but don't block if channel is full
1000 select {
1001 case ch <- data:
1002 default:
1003 // Channel is full, drop the message for this client
1004 }
1005 }
1006 session.eventsClientsMutex.Unlock()
1007 }
1008}
1009
1010// getShellPath returns the path to the shell to use
1011func getShellPath() string {
1012 // Try to use the user's preferred shell
1013 shell := os.Getenv("SHELL")
1014 if shell != "" {
1015 return shell
1016 }
1017
1018 // Default to bash on Unix-like systems
1019 if _, err := os.Stat("/bin/bash"); err == nil {
1020 return "/bin/bash"
1021 }
1022
1023 // Fall back to sh
1024 return "/bin/sh"
1025}
1026
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001027func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001028 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001029 build := "unknown build"
1030 bi, ok := debug.ReadBuildInfo()
1031 if ok {
1032 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1033 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001034 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1035 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001036 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001037 fmt.Fprintf(w, `<!doctype html>
1038 <html><head><title>sketch debug</title></head><body>
1039 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001040 pid %d<br>
1041 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001042 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001043 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1044 <li><a href="pprof/profile">pprof/profile</a></li>
1045 <li><a href="pprof/symbol">pprof/symbol</a></li>
1046 <li><a href="pprof/trace">pprof/trace</a></li>
1047 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001048 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001049 </ul>
1050 </body>
1051 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001052 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001053 })
1054 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1055 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1056 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1057 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1058 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001059
1060 // Add conversation history debug handler
1061 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1062 w.Header().Set("Content-Type", "application/json")
1063
1064 // Use type assertion to access the GetConvo method
1065 type ConvoProvider interface {
1066 GetConvo() loop.ConvoInterface
1067 }
1068
1069 if convoProvider, ok := agent.(ConvoProvider); ok {
1070 // Call the DebugJSON method to get the conversation history
1071 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1072 if err != nil {
1073 http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
1074 return
1075 }
1076
1077 // Write the JSON response
1078 w.Write(historyJSON)
1079 } else {
1080 http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
1081 }
1082 })
1083
Earl Lee2e463fb2025-04-17 11:22:22 -07001084 return mux
1085}
1086
1087// isValidGitSHA validates if a string looks like a valid git SHA hash.
1088// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1089func isValidGitSHA(sha string) bool {
1090 // Git SHA must be a hexadecimal string with at least 4 characters
1091 if len(sha) < 4 || len(sha) > 40 {
1092 return false
1093 }
1094
1095 // Check if the string only contains hexadecimal characters
1096 for _, char := range sha {
1097 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1098 return false
1099 }
1100 }
1101
1102 return true
1103}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001104
1105// /stream?from=N endpoint for Server-Sent Events
1106func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1107 w.Header().Set("Content-Type", "text/event-stream")
1108 w.Header().Set("Cache-Control", "no-cache")
1109 w.Header().Set("Connection", "keep-alive")
1110 w.Header().Set("Access-Control-Allow-Origin", "*")
1111
1112 // Extract the 'from' parameter
1113 fromParam := r.URL.Query().Get("from")
1114 var fromIndex int
1115 var err error
1116 if fromParam != "" {
1117 fromIndex, err = strconv.Atoi(fromParam)
1118 if err != nil {
1119 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1120 return
1121 }
1122 }
1123
1124 // Ensure 'from' is valid
1125 currentCount := s.agent.MessageCount()
1126 if fromIndex < 0 {
1127 fromIndex = 0
1128 } else if fromIndex > currentCount {
1129 fromIndex = currentCount
1130 }
1131
1132 // Send the current state immediately
1133 state := s.getState()
1134
1135 // Create JSON encoder
1136 encoder := json.NewEncoder(w)
1137
1138 // Send state as an event
1139 fmt.Fprintf(w, "event: state\n")
1140 fmt.Fprintf(w, "data: ")
1141 encoder.Encode(state)
1142 fmt.Fprintf(w, "\n\n")
1143
1144 if f, ok := w.(http.Flusher); ok {
1145 f.Flush()
1146 }
1147
1148 // Create a context for the SSE stream
1149 ctx := r.Context()
1150
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001151 // Setup heartbeat timer
1152 heartbeatTicker := time.NewTicker(45 * time.Second)
1153 defer heartbeatTicker.Stop()
1154
1155 // Create a channel for messages
1156 messageChan := make(chan *loop.AgentMessage, 10)
1157
Philip Zeyligereab12de2025-05-14 02:35:53 +00001158 // Create a channel for state transitions
1159 stateChan := make(chan *loop.StateTransition, 10)
1160
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001161 // Start a goroutine to read messages without blocking the heartbeat
1162 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001163 // Create an iterator to receive new messages as they arrive
1164 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1165 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001166 defer close(messageChan)
1167 for {
1168 // This can block, but it's in its own goroutine
1169 newMessage := iterator.Next()
1170 if newMessage == nil {
1171 // No message available (likely due to context cancellation)
1172 slog.InfoContext(ctx, "No more messages available, ending message stream")
1173 return
1174 }
1175
1176 select {
1177 case messageChan <- newMessage:
1178 // Message sent to channel
1179 case <-ctx.Done():
1180 // Context cancelled
1181 return
1182 }
1183 }
1184 }()
1185
Philip Zeyligereab12de2025-05-14 02:35:53 +00001186 // Start a goroutine to read state transitions
1187 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001188 // Create an iterator to receive state transitions
1189 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1190 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001191 defer close(stateChan)
1192 for {
1193 // This can block, but it's in its own goroutine
1194 newTransition := stateIterator.Next()
1195 if newTransition == nil {
1196 // No transition available (likely due to context cancellation)
1197 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1198 return
1199 }
1200
1201 select {
1202 case stateChan <- newTransition:
1203 // Transition sent to channel
1204 case <-ctx.Done():
1205 // Context cancelled
1206 return
1207 }
1208 }
1209 }()
1210
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001211 // Stay connected and stream real-time updates
1212 for {
1213 select {
1214 case <-heartbeatTicker.C:
1215 // Send heartbeat event
1216 fmt.Fprintf(w, "event: heartbeat\n")
1217 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1218
1219 // Flush to send the heartbeat immediately
1220 if f, ok := w.(http.Flusher); ok {
1221 f.Flush()
1222 }
1223
1224 case <-ctx.Done():
1225 // Client disconnected
1226 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1227 return
1228
Philip Zeyligereab12de2025-05-14 02:35:53 +00001229 case _, ok := <-stateChan:
1230 if !ok {
1231 // Channel closed
1232 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1233 return
1234 }
1235
1236 // Get updated state
1237 state = s.getState()
1238
1239 // Send updated state after the state transition
1240 fmt.Fprintf(w, "event: state\n")
1241 fmt.Fprintf(w, "data: ")
1242 encoder.Encode(state)
1243 fmt.Fprintf(w, "\n\n")
1244
1245 // Flush to send the state immediately
1246 if f, ok := w.(http.Flusher); ok {
1247 f.Flush()
1248 }
1249
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001250 case newMessage, ok := <-messageChan:
1251 if !ok {
1252 // Channel closed
1253 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1254 return
1255 }
1256
1257 // Send the new message as an event
1258 fmt.Fprintf(w, "event: message\n")
1259 fmt.Fprintf(w, "data: ")
1260 encoder.Encode(newMessage)
1261 fmt.Fprintf(w, "\n\n")
1262
1263 // Get updated state
1264 state = s.getState()
1265
1266 // Send updated state after the message
1267 fmt.Fprintf(w, "event: state\n")
1268 fmt.Fprintf(w, "data: ")
1269 encoder.Encode(state)
1270 fmt.Fprintf(w, "\n\n")
1271
1272 // Flush to send the message and state immediately
1273 if f, ok := w.(http.Flusher); ok {
1274 f.Flush()
1275 }
1276 }
1277 }
1278}
1279
1280// Helper function to get the current state
1281func (s *Server) getState() State {
1282 serverMessageCount := s.agent.MessageCount()
1283 totalUsage := s.agent.TotalUsage()
1284
Philip Zeyliger64f60462025-06-16 13:57:10 -07001285 // Get diff stats
1286 diffAdded, diffRemoved := s.agent.DiffStats()
1287
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001288 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001289 StateVersion: 2,
1290 MessageCount: serverMessageCount,
1291 TotalUsage: &totalUsage,
1292 Hostname: s.hostname,
1293 WorkingDir: getWorkingDir(),
1294 // TODO: Rename this field to sketch-base?
1295 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001296 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001297 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001298 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001299 OS: s.agent.OS(),
1300 OutsideHostname: s.agent.OutsideHostname(),
1301 InsideHostname: s.hostname,
1302 OutsideOS: s.agent.OutsideOS(),
1303 InsideOS: s.agent.OS(),
1304 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1305 InsideWorkingDir: getWorkingDir(),
1306 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001307 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001308 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1309 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1310 SessionID: s.agent.SessionID(),
1311 SSHAvailable: s.sshAvailable,
1312 SSHError: s.sshError,
1313 InContainer: s.agent.IsInContainer(),
1314 FirstMessageIndex: s.agent.FirstMessageIndex(),
1315 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001316 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001317 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001318 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001319 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001320 DiffLinesAdded: diffAdded,
1321 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001322 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001323 TokenContextWindow: s.agent.TokenContextWindow(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001324 }
1325}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001326
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001327// getOpenPorts retrieves the current open ports from the agent
1328func (s *Server) getOpenPorts() []Port {
1329 ports := s.agent.GetPorts()
1330 if ports == nil {
1331 return nil
1332 }
1333
1334 result := make([]Port, len(ports))
1335 for i, port := range ports {
1336 result[i] = Port{
1337 Proto: port.Proto,
1338 Port: port.Port,
1339 Process: port.Process,
1340 Pid: port.Pid,
1341 }
1342 }
1343 return result
1344}
1345
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001346func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1347 if r.Method != "GET" {
1348 w.WriteHeader(http.StatusMethodNotAllowed)
1349 return
1350 }
1351
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001352 // Get the git repository root directory from agent
1353 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001354
1355 // Parse query parameters
1356 query := r.URL.Query()
1357 commit := query.Get("commit")
1358 from := query.Get("from")
1359 to := query.Get("to")
1360
1361 // If commit is specified, use commit^ and commit as from and to
1362 if commit != "" {
1363 from = commit + "^"
1364 to = commit
1365 }
1366
1367 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001368 if from == "" {
1369 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001370 return
1371 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001372 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001373
1374 // Call the git_tools function
1375 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1376 if err != nil {
1377 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1378 return
1379 }
1380
1381 // Return the result as JSON
1382 w.Header().Set("Content-Type", "application/json")
1383 if err := json.NewEncoder(w).Encode(diff); err != nil {
1384 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1385 return
1386 }
1387}
1388
1389func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1390 if r.Method != "GET" {
1391 w.WriteHeader(http.StatusMethodNotAllowed)
1392 return
1393 }
1394
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001395 // Get the git repository root directory from agent
1396 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001397
1398 // Parse query parameters
1399 hash := r.URL.Query().Get("hash")
1400 if hash == "" {
1401 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1402 return
1403 }
1404
1405 // Call the git_tools function
1406 show, err := git_tools.GitShow(repoDir, hash)
1407 if err != nil {
1408 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1409 return
1410 }
1411
1412 // Create a JSON response
1413 response := map[string]string{
1414 "hash": hash,
1415 "output": show,
1416 }
1417
1418 // Return the result as JSON
1419 w.Header().Set("Content-Type", "application/json")
1420 if err := json.NewEncoder(w).Encode(response); err != nil {
1421 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1422 return
1423 }
1424}
1425
1426func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1427 if r.Method != "GET" {
1428 w.WriteHeader(http.StatusMethodNotAllowed)
1429 return
1430 }
1431
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001432 // Get the git repository root directory and initial commit from agent
1433 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001434 initialCommit := s.agent.SketchGitBaseRef()
1435
1436 // Call the git_tools function
1437 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1438 if err != nil {
1439 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1440 return
1441 }
1442
1443 // Return the result as JSON
1444 w.Header().Set("Content-Type", "application/json")
1445 if err := json.NewEncoder(w).Encode(log); err != nil {
1446 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1447 return
1448 }
1449}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001450
1451func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1452 if r.Method != "GET" {
1453 w.WriteHeader(http.StatusMethodNotAllowed)
1454 return
1455 }
1456
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001457 // Get the git repository root directory from agent
1458 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001459
1460 // Parse query parameters
1461 query := r.URL.Query()
1462 path := query.Get("path")
1463
1464 // Check if path is provided
1465 if path == "" {
1466 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1467 return
1468 }
1469
1470 // Get file content using GitCat
1471 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001472 switch {
1473 case err == nil:
1474 // continued below
1475 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001476 w.WriteHeader(http.StatusNoContent)
1477 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001478 default:
1479 http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001480 return
1481 }
1482
1483 // Return the content as JSON for consistency with other endpoints
1484 w.Header().Set("Content-Type", "application/json")
1485 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1486 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1487 return
1488 }
1489}
1490
1491func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1492 if r.Method != "POST" {
1493 w.WriteHeader(http.StatusMethodNotAllowed)
1494 return
1495 }
1496
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001497 // Get the git repository root directory from agent
1498 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001499
1500 // Parse request body
1501 var requestBody struct {
1502 Path string `json:"path"`
1503 Content string `json:"content"`
1504 }
1505
1506 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1507 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1508 return
1509 }
1510 defer r.Body.Close()
1511
1512 // Check if path is provided
1513 if requestBody.Path == "" {
1514 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1515 return
1516 }
1517
1518 // Save file content using GitSaveFile
1519 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1520 if err != nil {
1521 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1522 return
1523 }
1524
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001525 // Auto-commit the changes
1526 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1527 if err != nil {
1528 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1529 return
1530 }
1531
1532 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001533 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1534 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1535 return
1536 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001537
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001538 // Return simple success response
1539 w.WriteHeader(http.StatusOK)
1540 w.Write([]byte("ok"))
1541}