blob: 3f90f8bae6c7864b21090060b48254bd327e01f2 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
Sean McCulloughbaa2b592025-04-23 10:40:08 -07005 "context"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00006 "crypto/rand"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/base64"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00008 "encoding/hex"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "encoding/json"
10 "fmt"
11 "html"
12 "io"
13 "io/fs"
14 "log/slog"
15 "net/http"
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"
26 "syscall"
27 "time"
28
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000029 "sketch.dev/git_tools"
Philip Zeyliger176de792025-04-21 12:25:18 -070030 "sketch.dev/loop/server/gzhandler"
31
Earl Lee2e463fb2025-04-17 11:22:22 -070032 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000033 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070034 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070035 "sketch.dev/loop"
Philip Zeyliger2032b1c2025-04-23 19:40:42 -070036 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070037)
38
39// terminalSession represents a terminal session with its PTY and the event channel
40type terminalSession struct {
41 pty *os.File
42 eventsClients map[chan []byte]bool
43 lastEventClientID int
44 eventsClientsMutex sync.Mutex
45 cmd *exec.Cmd
46}
47
48// TerminalMessage represents a message sent from the client for terminal resize events
49type TerminalMessage struct {
50 Type string `json:"type"`
51 Cols uint16 `json:"cols"`
52 Rows uint16 `json:"rows"`
53}
54
55// TerminalResponse represents the response for a new terminal creation
56type TerminalResponse struct {
57 SessionID string `json:"sessionId"`
58}
59
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070060// TodoItem represents a single todo item for task management
61type TodoItem struct {
62 ID string `json:"id"`
63 Task string `json:"task"`
64 Status string `json:"status"` // queued, in-progress, completed
65}
66
67// TodoList represents a collection of todo items
68type TodoList struct {
69 Items []TodoItem `json:"items"`
70}
71
Sean McCulloughd9f13372025-04-21 15:08:49 -070072type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -070073 // null or 1: "old"
74 // 2: supports SSE for message updates
75 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070076 MessageCount int `json:"message_count"`
77 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
78 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070079 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070080 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000081 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070082 Hostname string `json:"hostname"` // deprecated
83 WorkingDir string `json:"working_dir"` // deprecated
84 OS string `json:"os"` // deprecated
85 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +000086 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070087 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
88 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
89 SessionID string `json:"session_id"`
90 SSHAvailable bool `json:"ssh_available"`
91 SSHError string `json:"ssh_error,omitempty"`
92 InContainer bool `json:"in_container"`
93 FirstMessageIndex int `json:"first_message_index"`
94 AgentState string `json:"agent_state,omitempty"`
95 OutsideHostname string `json:"outside_hostname,omitempty"`
96 InsideHostname string `json:"inside_hostname,omitempty"`
97 OutsideOS string `json:"outside_os,omitempty"`
98 InsideOS string `json:"inside_os,omitempty"`
99 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
100 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700101 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
102 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
103 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
104 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700105 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
106 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000107 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
108}
109
110// Port represents an open TCP port
111type Port struct {
112 Proto string `json:"proto"` // "tcp" or "udp"
113 Port uint16 `json:"port"` // port number
114 Process string `json:"process"` // optional process name
115 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700116}
117
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700118type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700119 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
120 HostAddr string `json:"host_addr"`
121
122 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000123 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
124 SSHServerIdentity []byte `json:"ssh_server_identity"`
125 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
126 SSHHostCertificate []byte `json:"ssh_host_certificate"`
127 SSHAvailable bool `json:"ssh_available"`
128 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700129}
130
Earl Lee2e463fb2025-04-17 11:22:22 -0700131// Server serves sketch HTTP. Server implements http.Handler.
132type Server struct {
133 mux *http.ServeMux
134 agent loop.CodingAgent
135 hostname string
136 logFile *os.File
137 // Mutex to protect terminalSessions
138 ptyMutex sync.Mutex
139 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000140 sshAvailable bool
141 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700142}
143
144func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000145 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
146 if port := s.ParsePortProxyHost(r.Host); port != "" {
147 s.proxyToPort(w, r, port)
148 return
149 }
150
Earl Lee2e463fb2025-04-17 11:22:22 -0700151 s.mux.ServeHTTP(w, r)
152}
153
Philip Zeyligera9710d72025-07-02 02:50:14 +0000154// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
155func (s *Server) ParsePortProxyHost(host string) string {
156 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
157 hostname := host
158 if idx := strings.LastIndex(host, ":"); idx > 0 {
159 hostname = host[:idx]
160 }
161
162 // Check if hostname matches p<port>.localhost pattern
163 if strings.HasSuffix(hostname, ".localhost") {
164 prefix := strings.TrimSuffix(hostname, ".localhost")
165 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
166 port := prefix[1:] // Remove 'p' prefix
167 // Basic validation - port should be numeric and in valid range
168 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
169 return port
170 }
171 }
172 }
173
174 return ""
175}
176
177// proxyToPort proxies the request to localhost:<port>
178func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
179 // Create a reverse proxy to localhost:<port>
180 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
181 if err != nil {
182 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
183 return
184 }
185
186 proxy := httputil.NewSingleHostReverseProxy(target)
187
188 // Customize the Director to modify the request
189 originalDirector := proxy.Director
190 proxy.Director = func(req *http.Request) {
191 originalDirector(req)
192 // Set the target host
193 req.URL.Host = target.Host
194 req.URL.Scheme = target.Scheme
195 req.Host = target.Host
196 }
197
198 // Handle proxy errors
199 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
200 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
201 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
202 }
203
204 proxy.ServeHTTP(w, r)
205}
206
Earl Lee2e463fb2025-04-17 11:22:22 -0700207// New creates a new HTTP server.
208func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
209 s := &Server{
210 mux: http.NewServeMux(),
211 agent: agent,
212 hostname: getHostname(),
213 logFile: logFile,
214 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000215 sshAvailable: false,
216 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 }
218
219 webBundle, err := webui.Build()
220 if err != nil {
221 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
222 }
223
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000224 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000225
226 // Git tool endpoints
227 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
228 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700229 s.mux.HandleFunc("/git/cat", s.handleGitCat)
230 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000231 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
232
Earl Lee2e463fb2025-04-17 11:22:22 -0700233 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
234 // Check if a specific commit hash was requested
235 commit := r.URL.Query().Get("commit")
236
237 // Get the diff, optionally for a specific commit
238 var diff string
239 var err error
240 if commit != "" {
241 // Validate the commit hash format
242 if !isValidGitSHA(commit) {
243 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
244 return
245 }
246
247 diff, err = agent.Diff(&commit)
248 } else {
249 diff, err = agent.Diff(nil)
250 }
251
252 if err != nil {
253 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
254 return
255 }
256
257 w.Header().Set("Content-Type", "text/plain")
258 w.Write([]byte(diff))
259 })
260
261 // Handler for initialization called by host sketch binary when inside docker.
262 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
263 defer func() {
264 if err := recover(); err != nil {
265 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
266
267 // Return an error response to the client
268 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
269 }
270 }()
271
272 if r.Method != "POST" {
273 http.Error(w, "POST required", http.StatusBadRequest)
274 return
275 }
276
277 body, err := io.ReadAll(r.Body)
278 r.Body.Close()
279 if err != nil {
280 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
281 return
282 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700283
284 m := &InitRequest{}
285 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700286 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
287 return
288 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700289
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000290 // Store SSH availability info
291 s.sshAvailable = m.SSHAvailable
292 s.sshError = m.SSHError
293
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700294 // Start the SSH server if the init request included ssh keys.
295 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
296 go func() {
297 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000298 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700299 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000300 // Update SSH error if server fails to start
301 s.sshAvailable = false
302 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700303 }
304 }()
305 }
306
Earl Lee2e463fb2025-04-17 11:22:22 -0700307 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700308 InDocker: true,
309 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 }
311 if err := agent.Init(ini); err != nil {
312 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
313 return
314 }
315 w.Header().Set("Content-Type", "application/json")
316 io.WriteString(w, "{}\n")
317 })
318
319 // Handler for /messages?start=N&end=M (start/end are optional)
320 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
321 w.Header().Set("Content-Type", "application/json")
322
323 // Extract query parameters for range
324 var start, end int
325 var err error
326
327 currentCount := agent.MessageCount()
328
329 startParam := r.URL.Query().Get("start")
330 if startParam != "" {
331 start, err = strconv.Atoi(startParam)
332 if err != nil {
333 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
334 return
335 }
336 }
337
338 endParam := r.URL.Query().Get("end")
339 if endParam != "" {
340 end, err = strconv.Atoi(endParam)
341 if err != nil {
342 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
343 return
344 }
345 } else {
346 end = currentCount
347 }
348
349 if start < 0 || start > end || end > currentCount {
350 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
351 return
352 }
353
354 start = max(0, start)
355 end = min(agent.MessageCount(), end)
356 messages := agent.Messages(start, end)
357
358 // Create a JSON encoder with indentation for pretty-printing
359 encoder := json.NewEncoder(w)
360 encoder.SetIndent("", " ") // Two spaces for each indentation level
361
362 err = encoder.Encode(messages)
363 if err != nil {
364 http.Error(w, err.Error(), http.StatusInternalServerError)
365 }
366 })
367
368 // Handler for /logs - displays the contents of the log file
369 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
370 if s.logFile == nil {
371 http.Error(w, "log file not set", http.StatusNotFound)
372 return
373 }
374 logContents, err := os.ReadFile(s.logFile.Name())
375 if err != nil {
376 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
377 return
378 }
379 w.Header().Set("Content-Type", "text/html; charset=utf-8")
380 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
381 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
382 fmt.Fprintf(w, "</body>\n</html>")
383 })
384
385 // Handler for /download - downloads both messages and status as a JSON file
386 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
387 // Set headers for file download
388 w.Header().Set("Content-Type", "application/octet-stream")
389
390 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
391 timestamp := time.Now().Format("20060102-150405")
392 filename := fmt.Sprintf("sketch-%s.json", timestamp)
393
394 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
395
396 // Get all messages
397 messageCount := agent.MessageCount()
398 messages := agent.Messages(0, messageCount)
399
400 // Get status information (usage and other metadata)
401 totalUsage := agent.TotalUsage()
402 hostname := getHostname()
403 workingDir := getWorkingDir()
404
405 // Create a combined structure with all information
406 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700407 Messages []loop.AgentMessage `json:"messages"`
408 MessageCount int `json:"message_count"`
409 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
410 Hostname string `json:"hostname"`
411 WorkingDir string `json:"working_dir"`
412 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700413 }{
414 Messages: messages,
415 MessageCount: messageCount,
416 TotalUsage: totalUsage,
417 Hostname: hostname,
418 WorkingDir: workingDir,
419 DownloadTime: time.Now().Format(time.RFC3339),
420 }
421
422 // Marshal the JSON with indentation for better readability
423 jsonData, err := json.MarshalIndent(downloadData, "", " ")
424 if err != nil {
425 http.Error(w, err.Error(), http.StatusInternalServerError)
426 return
427 }
428 w.Write(jsonData)
429 })
430
431 // The latter doesn't return until the number of messages has changed (from seen
432 // or from when this was called.)
433 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
434 pollParam := r.URL.Query().Get("poll")
435 seenParam := r.URL.Query().Get("seen")
436
437 // Get the client's current message count (if provided)
438 clientMessageCount := -1
439 var err error
440 if seenParam != "" {
441 clientMessageCount, err = strconv.Atoi(seenParam)
442 if err != nil {
443 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
444 return
445 }
446 }
447
448 serverMessageCount := agent.MessageCount()
449
450 // Let lazy clients not have to specify this.
451 if clientMessageCount == -1 {
452 clientMessageCount = serverMessageCount
453 }
454
455 if pollParam == "true" {
456 ch := make(chan string)
457 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700458 it := agent.NewIterator(r.Context(), clientMessageCount)
459 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700460 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700461 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700462 }()
463 select {
464 case <-r.Context().Done():
465 slog.DebugContext(r.Context(), "abandoned poll request")
466 return
467 case <-time.After(90 * time.Second):
468 // Let the user call /state again to get the latest to limit how long our long polls hang out.
469 slog.DebugContext(r.Context(), "longish poll request")
470 break
471 case <-ch:
472 break
473 }
474 }
475
Earl Lee2e463fb2025-04-17 11:22:22 -0700476 w.Header().Set("Content-Type", "application/json")
477
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000478 // Use the shared getState function
479 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700480
481 // Create a JSON encoder with indentation for pretty-printing
482 encoder := json.NewEncoder(w)
483 encoder.SetIndent("", " ") // Two spaces for each indentation level
484
485 err = encoder.Encode(state)
486 if err != nil {
487 http.Error(w, err.Error(), http.StatusInternalServerError)
488 }
489 })
490
Philip Zeyliger176de792025-04-21 12:25:18 -0700491 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700492
493 // Terminal WebSocket handler
494 // Terminal endpoints - predefined terminals 1-9
495 // TODO: The UI doesn't actually know how to use terminals 2-9!
496 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
497 if r.Method != http.MethodGet {
498 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
499 return
500 }
501 pathParts := strings.Split(r.URL.Path, "/")
502 if len(pathParts) < 4 {
503 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
504 return
505 }
506
507 sessionID := pathParts[3]
508 // Validate that the terminal ID is between 1-9
509 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
510 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
511 return
512 }
513
514 s.handleTerminalEvents(w, r, sessionID)
515 })
516
517 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
518 if r.Method != http.MethodPost {
519 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
520 return
521 }
522 pathParts := strings.Split(r.URL.Path, "/")
523 if len(pathParts) < 4 {
524 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
525 return
526 }
527 sessionID := pathParts[3]
528 s.handleTerminalInput(w, r, sessionID)
529 })
530
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700531 // Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
Earl Lee2e463fb2025-04-17 11:22:22 -0700532 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700533 // Check URL parameters for interface selection
534 queryParams := r.URL.Query()
535
536 // Check if mobile interface is requested (?m parameter)
537 if queryParams.Has("m") {
538 // Serve the mobile-app-shell.html file
539 data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
540 if err != nil {
541 http.Error(w, "Mobile interface not found", http.StatusNotFound)
542 return
543 }
544 w.Header().Set("Content-Type", "text/html")
545 w.Write(data)
546 return
547 }
548
549 // Check if desktop interface is explicitly requested (?d parameter)
550 // or serve desktop by default
Sean McCullough86b56862025-04-18 13:04:03 -0700551 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700552 if err != nil {
553 http.Error(w, "File not found", http.StatusNotFound)
554 return
555 }
556 w.Header().Set("Content-Type", "text/html")
557 w.Write(data)
558 })
559
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700560 // Handler for /commit-description - returns the description of a git commit
561 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
562 if r.Method != http.MethodGet {
563 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
564 return
565 }
566
567 // Get the revision parameter
568 revision := r.URL.Query().Get("revision")
569 if revision == "" {
570 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
571 return
572 }
573
574 // Run git command to get commit description
575 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
576 // Use the working directory from the agent
577 cmd.Dir = s.agent.WorkingDir()
578
579 output, err := cmd.CombinedOutput()
580 if err != nil {
581 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
582 return
583 }
584
585 // Prepare the response
586 resp := map[string]string{
587 "description": strings.TrimSpace(string(output)),
588 }
589
590 w.Header().Set("Content-Type", "application/json")
591 if err := json.NewEncoder(w).Encode(resp); err != nil {
592 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
593 }
594 })
595
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000596 // Handler for /screenshot/{id} - serves screenshot images
597 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
598 if r.Method != http.MethodGet {
599 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
600 return
601 }
602
603 // Extract the screenshot ID from the path
604 pathParts := strings.Split(r.URL.Path, "/")
605 if len(pathParts) < 3 {
606 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
607 return
608 }
609
610 screenshotID := pathParts[2]
611
612 // Validate the ID format (prevent directory traversal)
613 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
614 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
615 return
616 }
617
618 // Get the screenshot file path
619 filePath := browse.GetScreenshotPath(screenshotID)
620
621 // Check if the file exists
622 if _, err := os.Stat(filePath); os.IsNotExist(err) {
623 http.Error(w, "Screenshot not found", http.StatusNotFound)
624 return
625 }
626
627 // Serve the file
628 w.Header().Set("Content-Type", "image/png")
629 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
630 http.ServeFile(w, r, filePath)
631 })
632
Earl Lee2e463fb2025-04-17 11:22:22 -0700633 // Handler for POST /chat
634 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
635 if r.Method != http.MethodPost {
636 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
637 return
638 }
639
640 // Parse the request body
641 var requestBody struct {
642 Message string `json:"message"`
643 }
644
645 decoder := json.NewDecoder(r.Body)
646 if err := decoder.Decode(&requestBody); err != nil {
647 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
648 return
649 }
650 defer r.Body.Close()
651
652 if requestBody.Message == "" {
653 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
654 return
655 }
656
657 agent.UserMessage(r.Context(), requestBody.Message)
658
659 w.WriteHeader(http.StatusOK)
660 })
661
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000662 // Handler for POST /upload - uploads a file to /tmp
663 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
664 if r.Method != http.MethodPost {
665 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
666 return
667 }
668
669 // Limit to 10MB file size
670 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
671
672 // Parse the multipart form
673 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
674 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
675 return
676 }
677
678 // Get the file from the multipart form
679 file, handler, err := r.FormFile("file")
680 if err != nil {
681 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
682 return
683 }
684 defer file.Close()
685
686 // Generate a unique ID (8 random bytes converted to 16 hex chars)
687 randBytes := make([]byte, 8)
688 if _, err := rand.Read(randBytes); err != nil {
689 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
690 return
691 }
692
693 // Get file extension from the original filename
694 ext := filepath.Ext(handler.Filename)
695
696 // Create a unique filename in the /tmp directory
697 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
698
699 // Create the destination file
700 destFile, err := os.Create(filename)
701 if err != nil {
702 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
703 return
704 }
705 defer destFile.Close()
706
707 // Copy the file contents to the destination file
708 if _, err := io.Copy(destFile, file); err != nil {
709 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
710 return
711 }
712
713 // Return the path to the saved file
714 w.Header().Set("Content-Type", "application/json")
715 json.NewEncoder(w).Encode(map[string]string{"path": filename})
716 })
717
Earl Lee2e463fb2025-04-17 11:22:22 -0700718 // Handler for /cancel - cancels the current inner loop in progress
719 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
720 if r.Method != http.MethodPost {
721 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
722 return
723 }
724
725 // Parse the request body (optional)
726 var requestBody struct {
727 Reason string `json:"reason"`
728 ToolCallID string `json:"tool_call_id"`
729 }
730
731 decoder := json.NewDecoder(r.Body)
732 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
733 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
734 return
735 }
736 defer r.Body.Close()
737
738 cancelReason := "user requested cancellation"
739 if requestBody.Reason != "" {
740 cancelReason = requestBody.Reason
741 }
742
743 if requestBody.ToolCallID != "" {
744 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
745 if err != nil {
746 http.Error(w, err.Error(), http.StatusBadRequest)
747 return
748 }
749 // Return a success response
750 w.Header().Set("Content-Type", "application/json")
751 json.NewEncoder(w).Encode(map[string]string{
752 "status": "cancelled",
753 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700754 "reason": cancelReason,
755 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700756 return
757 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000758 // Call the CancelTurn method
759 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700760 // Return a success response
761 w.Header().Set("Content-Type", "application/json")
762 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
763 })
764
Pokey Rule397871d2025-05-19 15:02:45 +0100765 // Handler for /end - shuts down the inner sketch process
766 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
767 if r.Method != http.MethodPost {
768 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
769 return
770 }
771
772 // Parse the request body (optional)
773 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700774 Reason string `json:"reason"`
775 Happy *bool `json:"happy,omitempty"`
776 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100777 }
778
779 decoder := json.NewDecoder(r.Body)
780 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
781 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
782 return
783 }
784 defer r.Body.Close()
785
786 endReason := "user requested end of session"
787 if requestBody.Reason != "" {
788 endReason = requestBody.Reason
789 }
790
791 // Send success response before exiting
792 w.Header().Set("Content-Type", "application/json")
793 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
794 if f, ok := w.(http.Flusher); ok {
795 f.Flush()
796 }
797
798 // Log that we're shutting down
799 slog.Info("Ending session", "reason", endReason)
800
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000801 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100802 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000803 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100804 os.Exit(0)
805 }()
806 })
807
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 debugMux := initDebugMux()
809 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
810 debugMux.ServeHTTP(w, r)
811 })
812
813 return s, nil
814}
815
816// Utility functions
817func getHostname() string {
818 hostname, err := os.Hostname()
819 if err != nil {
820 return "unknown"
821 }
822 return hostname
823}
824
825func getWorkingDir() string {
826 wd, err := os.Getwd()
827 if err != nil {
828 return "unknown"
829 }
830 return wd
831}
832
833// createTerminalSession creates a new terminal session with the given ID
834func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
835 // Start a new shell process
836 shellPath := getShellPath()
837 cmd := exec.Command(shellPath)
838
839 // Get working directory from the agent if possible
840 workDir := getWorkingDir()
841 cmd.Dir = workDir
842
843 // Set up environment
844 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
845
846 // Start the command with a pty
847 ptmx, err := pty.Start(cmd)
848 if err != nil {
849 slog.Error("Failed to start pty", "error", err)
850 return nil, err
851 }
852
853 // Create the terminal session
854 session := &terminalSession{
855 pty: ptmx,
856 eventsClients: make(map[chan []byte]bool),
857 cmd: cmd,
858 }
859
860 // Start goroutine to read from pty and broadcast to all connected SSE clients
861 go s.readFromPtyAndBroadcast(sessionID, session)
862
863 return session, nil
864} // handleTerminalEvents handles SSE connections for terminal output
865func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
866 // Check if the session exists, if not, create it
867 s.ptyMutex.Lock()
868 session, exists := s.terminalSessions[sessionID]
869
870 if !exists {
871 // Create a new terminal session
872 var err error
873 session, err = s.createTerminalSession(sessionID)
874 if err != nil {
875 s.ptyMutex.Unlock()
876 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
877 return
878 }
879
880 // Store the new session
881 s.terminalSessions[sessionID] = session
882 }
883 s.ptyMutex.Unlock()
884
885 // Set headers for SSE
886 w.Header().Set("Content-Type", "text/event-stream")
887 w.Header().Set("Cache-Control", "no-cache")
888 w.Header().Set("Connection", "keep-alive")
889 w.Header().Set("Access-Control-Allow-Origin", "*")
890
891 // Create a channel for this client
892 events := make(chan []byte, 4096) // Buffer to prevent blocking
893
894 // Register this client's channel
895 session.eventsClientsMutex.Lock()
896 clientID := session.lastEventClientID + 1
897 session.lastEventClientID = clientID
898 session.eventsClients[events] = true
899 session.eventsClientsMutex.Unlock()
900
901 // When the client disconnects, remove their channel
902 defer func() {
903 session.eventsClientsMutex.Lock()
904 delete(session.eventsClients, events)
905 close(events)
906 session.eventsClientsMutex.Unlock()
907 }()
908
909 // Flush to send headers to client immediately
910 if f, ok := w.(http.Flusher); ok {
911 f.Flush()
912 }
913
914 // Send events to the client as they arrive
915 for {
916 select {
917 case <-r.Context().Done():
918 return
919 case data := <-events:
920 // Format as SSE with base64 encoding
921 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
922
923 // Flush the data immediately
924 if f, ok := w.(http.Flusher); ok {
925 f.Flush()
926 }
927 }
928 }
929}
930
931// handleTerminalInput processes input to the terminal
932func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
933 // Check if the session exists
934 s.ptyMutex.Lock()
935 session, exists := s.terminalSessions[sessionID]
936 s.ptyMutex.Unlock()
937
938 if !exists {
939 http.Error(w, "Terminal session not found", http.StatusNotFound)
940 return
941 }
942
943 // Read the request body (terminal input or resize command)
944 body, err := io.ReadAll(r.Body)
945 if err != nil {
946 http.Error(w, "Failed to read request body", http.StatusBadRequest)
947 return
948 }
949
950 // Check if it's a resize message
951 if len(body) > 0 && body[0] == '{' {
952 var msg TerminalMessage
953 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
954 if msg.Cols > 0 && msg.Rows > 0 {
955 pty.Setsize(session.pty, &pty.Winsize{
956 Cols: msg.Cols,
957 Rows: msg.Rows,
958 })
959
960 // Respond with success
961 w.WriteHeader(http.StatusOK)
962 return
963 }
964 }
965 }
966
967 // Regular terminal input
968 _, err = session.pty.Write(body)
969 if err != nil {
970 slog.Error("Failed to write to pty", "error", err)
971 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
972 return
973 }
974
975 // Respond with success
976 w.WriteHeader(http.StatusOK)
977}
978
979// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
980func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
981 buf := make([]byte, 4096)
982 defer func() {
983 // Clean up when done
984 s.ptyMutex.Lock()
985 delete(s.terminalSessions, sessionID)
986 s.ptyMutex.Unlock()
987
988 // Close the PTY
989 session.pty.Close()
990
991 // Ensure process is terminated
992 if session.cmd.Process != nil {
993 session.cmd.Process.Signal(syscall.SIGTERM)
994 time.Sleep(100 * time.Millisecond)
995 session.cmd.Process.Kill()
996 }
997
998 // Close all client channels
999 session.eventsClientsMutex.Lock()
1000 for ch := range session.eventsClients {
1001 delete(session.eventsClients, ch)
1002 close(ch)
1003 }
1004 session.eventsClientsMutex.Unlock()
1005 }()
1006
1007 for {
1008 n, err := session.pty.Read(buf)
1009 if err != nil {
1010 if err != io.EOF {
1011 slog.Error("Failed to read from pty", "error", err)
1012 }
1013 break
1014 }
1015
1016 // Make a copy of the data for each client
1017 data := make([]byte, n)
1018 copy(data, buf[:n])
1019
1020 // Broadcast to all connected clients
1021 session.eventsClientsMutex.Lock()
1022 for ch := range session.eventsClients {
1023 // Try to send, but don't block if channel is full
1024 select {
1025 case ch <- data:
1026 default:
1027 // Channel is full, drop the message for this client
1028 }
1029 }
1030 session.eventsClientsMutex.Unlock()
1031 }
1032}
1033
1034// getShellPath returns the path to the shell to use
1035func getShellPath() string {
1036 // Try to use the user's preferred shell
1037 shell := os.Getenv("SHELL")
1038 if shell != "" {
1039 return shell
1040 }
1041
1042 // Default to bash on Unix-like systems
1043 if _, err := os.Stat("/bin/bash"); err == nil {
1044 return "/bin/bash"
1045 }
1046
1047 // Fall back to sh
1048 return "/bin/sh"
1049}
1050
1051func initDebugMux() *http.ServeMux {
1052 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001053 build := "unknown build"
1054 bi, ok := debug.ReadBuildInfo()
1055 if ok {
1056 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1057 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1059 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001060 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 fmt.Fprintf(w, `<!doctype html>
1062 <html><head><title>sketch debug</title></head><body>
1063 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001064 pid %d<br>
1065 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001066 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001067 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1068 <li><a href="pprof/profile">pprof/profile</a></li>
1069 <li><a href="pprof/symbol">pprof/symbol</a></li>
1070 <li><a href="pprof/trace">pprof/trace</a></li>
1071 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001072 </ul>
1073 </body>
1074 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001075 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 })
1077 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1078 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1079 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1080 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1081 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1082 return mux
1083}
1084
1085// isValidGitSHA validates if a string looks like a valid git SHA hash.
1086// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1087func isValidGitSHA(sha string) bool {
1088 // Git SHA must be a hexadecimal string with at least 4 characters
1089 if len(sha) < 4 || len(sha) > 40 {
1090 return false
1091 }
1092
1093 // Check if the string only contains hexadecimal characters
1094 for _, char := range sha {
1095 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1096 return false
1097 }
1098 }
1099
1100 return true
1101}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001102
1103// /stream?from=N endpoint for Server-Sent Events
1104func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1105 w.Header().Set("Content-Type", "text/event-stream")
1106 w.Header().Set("Cache-Control", "no-cache")
1107 w.Header().Set("Connection", "keep-alive")
1108 w.Header().Set("Access-Control-Allow-Origin", "*")
1109
1110 // Extract the 'from' parameter
1111 fromParam := r.URL.Query().Get("from")
1112 var fromIndex int
1113 var err error
1114 if fromParam != "" {
1115 fromIndex, err = strconv.Atoi(fromParam)
1116 if err != nil {
1117 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1118 return
1119 }
1120 }
1121
1122 // Ensure 'from' is valid
1123 currentCount := s.agent.MessageCount()
1124 if fromIndex < 0 {
1125 fromIndex = 0
1126 } else if fromIndex > currentCount {
1127 fromIndex = currentCount
1128 }
1129
1130 // Send the current state immediately
1131 state := s.getState()
1132
1133 // Create JSON encoder
1134 encoder := json.NewEncoder(w)
1135
1136 // Send state as an event
1137 fmt.Fprintf(w, "event: state\n")
1138 fmt.Fprintf(w, "data: ")
1139 encoder.Encode(state)
1140 fmt.Fprintf(w, "\n\n")
1141
1142 if f, ok := w.(http.Flusher); ok {
1143 f.Flush()
1144 }
1145
1146 // Create a context for the SSE stream
1147 ctx := r.Context()
1148
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001149 // Setup heartbeat timer
1150 heartbeatTicker := time.NewTicker(45 * time.Second)
1151 defer heartbeatTicker.Stop()
1152
1153 // Create a channel for messages
1154 messageChan := make(chan *loop.AgentMessage, 10)
1155
Philip Zeyligereab12de2025-05-14 02:35:53 +00001156 // Create a channel for state transitions
1157 stateChan := make(chan *loop.StateTransition, 10)
1158
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001159 // Start a goroutine to read messages without blocking the heartbeat
1160 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001161 // Create an iterator to receive new messages as they arrive
1162 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1163 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001164 defer close(messageChan)
1165 for {
1166 // This can block, but it's in its own goroutine
1167 newMessage := iterator.Next()
1168 if newMessage == nil {
1169 // No message available (likely due to context cancellation)
1170 slog.InfoContext(ctx, "No more messages available, ending message stream")
1171 return
1172 }
1173
1174 select {
1175 case messageChan <- newMessage:
1176 // Message sent to channel
1177 case <-ctx.Done():
1178 // Context cancelled
1179 return
1180 }
1181 }
1182 }()
1183
Philip Zeyligereab12de2025-05-14 02:35:53 +00001184 // Start a goroutine to read state transitions
1185 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001186 // Create an iterator to receive state transitions
1187 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1188 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001189 defer close(stateChan)
1190 for {
1191 // This can block, but it's in its own goroutine
1192 newTransition := stateIterator.Next()
1193 if newTransition == nil {
1194 // No transition available (likely due to context cancellation)
1195 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1196 return
1197 }
1198
1199 select {
1200 case stateChan <- newTransition:
1201 // Transition sent to channel
1202 case <-ctx.Done():
1203 // Context cancelled
1204 return
1205 }
1206 }
1207 }()
1208
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001209 // Stay connected and stream real-time updates
1210 for {
1211 select {
1212 case <-heartbeatTicker.C:
1213 // Send heartbeat event
1214 fmt.Fprintf(w, "event: heartbeat\n")
1215 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1216
1217 // Flush to send the heartbeat immediately
1218 if f, ok := w.(http.Flusher); ok {
1219 f.Flush()
1220 }
1221
1222 case <-ctx.Done():
1223 // Client disconnected
1224 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1225 return
1226
Philip Zeyligereab12de2025-05-14 02:35:53 +00001227 case _, ok := <-stateChan:
1228 if !ok {
1229 // Channel closed
1230 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1231 return
1232 }
1233
1234 // Get updated state
1235 state = s.getState()
1236
1237 // Send updated state after the state transition
1238 fmt.Fprintf(w, "event: state\n")
1239 fmt.Fprintf(w, "data: ")
1240 encoder.Encode(state)
1241 fmt.Fprintf(w, "\n\n")
1242
1243 // Flush to send the state immediately
1244 if f, ok := w.(http.Flusher); ok {
1245 f.Flush()
1246 }
1247
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001248 case newMessage, ok := <-messageChan:
1249 if !ok {
1250 // Channel closed
1251 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1252 return
1253 }
1254
1255 // Send the new message as an event
1256 fmt.Fprintf(w, "event: message\n")
1257 fmt.Fprintf(w, "data: ")
1258 encoder.Encode(newMessage)
1259 fmt.Fprintf(w, "\n\n")
1260
1261 // Get updated state
1262 state = s.getState()
1263
1264 // Send updated state after the message
1265 fmt.Fprintf(w, "event: state\n")
1266 fmt.Fprintf(w, "data: ")
1267 encoder.Encode(state)
1268 fmt.Fprintf(w, "\n\n")
1269
1270 // Flush to send the message and state immediately
1271 if f, ok := w.(http.Flusher); ok {
1272 f.Flush()
1273 }
1274 }
1275 }
1276}
1277
1278// Helper function to get the current state
1279func (s *Server) getState() State {
1280 serverMessageCount := s.agent.MessageCount()
1281 totalUsage := s.agent.TotalUsage()
1282
Philip Zeyliger64f60462025-06-16 13:57:10 -07001283 // Get diff stats
1284 diffAdded, diffRemoved := s.agent.DiffStats()
1285
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001286 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001287 StateVersion: 2,
1288 MessageCount: serverMessageCount,
1289 TotalUsage: &totalUsage,
1290 Hostname: s.hostname,
1291 WorkingDir: getWorkingDir(),
1292 // TODO: Rename this field to sketch-base?
1293 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001294 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001295 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001296 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001297 OS: s.agent.OS(),
1298 OutsideHostname: s.agent.OutsideHostname(),
1299 InsideHostname: s.hostname,
1300 OutsideOS: s.agent.OutsideOS(),
1301 InsideOS: s.agent.OS(),
1302 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1303 InsideWorkingDir: getWorkingDir(),
1304 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001305 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001306 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1307 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1308 SessionID: s.agent.SessionID(),
1309 SSHAvailable: s.sshAvailable,
1310 SSHError: s.sshError,
1311 InContainer: s.agent.IsInContainer(),
1312 FirstMessageIndex: s.agent.FirstMessageIndex(),
1313 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001314 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001315 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001316 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001317 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001318 DiffLinesAdded: diffAdded,
1319 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001320 OpenPorts: s.getOpenPorts(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001321 }
1322}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001323
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001324// getOpenPorts retrieves the current open ports from the agent
1325func (s *Server) getOpenPorts() []Port {
1326 ports := s.agent.GetPorts()
1327 if ports == nil {
1328 return nil
1329 }
1330
1331 result := make([]Port, len(ports))
1332 for i, port := range ports {
1333 result[i] = Port{
1334 Proto: port.Proto,
1335 Port: port.Port,
1336 Process: port.Process,
1337 Pid: port.Pid,
1338 }
1339 }
1340 return result
1341}
1342
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001343func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1344 if r.Method != "GET" {
1345 w.WriteHeader(http.StatusMethodNotAllowed)
1346 return
1347 }
1348
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001349 // Get the git repository root directory from agent
1350 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001351
1352 // Parse query parameters
1353 query := r.URL.Query()
1354 commit := query.Get("commit")
1355 from := query.Get("from")
1356 to := query.Get("to")
1357
1358 // If commit is specified, use commit^ and commit as from and to
1359 if commit != "" {
1360 from = commit + "^"
1361 to = commit
1362 }
1363
1364 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001365 if from == "" {
1366 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001367 return
1368 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001369 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001370
1371 // Call the git_tools function
1372 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1373 if err != nil {
1374 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1375 return
1376 }
1377
1378 // Return the result as JSON
1379 w.Header().Set("Content-Type", "application/json")
1380 if err := json.NewEncoder(w).Encode(diff); err != nil {
1381 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1382 return
1383 }
1384}
1385
1386func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1387 if r.Method != "GET" {
1388 w.WriteHeader(http.StatusMethodNotAllowed)
1389 return
1390 }
1391
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001392 // Get the git repository root directory from agent
1393 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001394
1395 // Parse query parameters
1396 hash := r.URL.Query().Get("hash")
1397 if hash == "" {
1398 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1399 return
1400 }
1401
1402 // Call the git_tools function
1403 show, err := git_tools.GitShow(repoDir, hash)
1404 if err != nil {
1405 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1406 return
1407 }
1408
1409 // Create a JSON response
1410 response := map[string]string{
1411 "hash": hash,
1412 "output": show,
1413 }
1414
1415 // Return the result as JSON
1416 w.Header().Set("Content-Type", "application/json")
1417 if err := json.NewEncoder(w).Encode(response); err != nil {
1418 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1419 return
1420 }
1421}
1422
1423func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1424 if r.Method != "GET" {
1425 w.WriteHeader(http.StatusMethodNotAllowed)
1426 return
1427 }
1428
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001429 // Get the git repository root directory and initial commit from agent
1430 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001431 initialCommit := s.agent.SketchGitBaseRef()
1432
1433 // Call the git_tools function
1434 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1435 if err != nil {
1436 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1437 return
1438 }
1439
1440 // Return the result as JSON
1441 w.Header().Set("Content-Type", "application/json")
1442 if err := json.NewEncoder(w).Encode(log); err != nil {
1443 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1444 return
1445 }
1446}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001447
1448func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1449 if r.Method != "GET" {
1450 w.WriteHeader(http.StatusMethodNotAllowed)
1451 return
1452 }
1453
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001454 // Get the git repository root directory from agent
1455 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001456
1457 // Parse query parameters
1458 query := r.URL.Query()
1459 path := query.Get("path")
1460
1461 // Check if path is provided
1462 if path == "" {
1463 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1464 return
1465 }
1466
1467 // Get file content using GitCat
1468 content, err := git_tools.GitCat(repoDir, path)
1469 if err != nil {
1470 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1471 return
1472 }
1473
1474 // Return the content as JSON for consistency with other endpoints
1475 w.Header().Set("Content-Type", "application/json")
1476 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1477 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1478 return
1479 }
1480}
1481
1482func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1483 if r.Method != "POST" {
1484 w.WriteHeader(http.StatusMethodNotAllowed)
1485 return
1486 }
1487
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001488 // Get the git repository root directory from agent
1489 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001490
1491 // Parse request body
1492 var requestBody struct {
1493 Path string `json:"path"`
1494 Content string `json:"content"`
1495 }
1496
1497 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1498 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1499 return
1500 }
1501 defer r.Body.Close()
1502
1503 // Check if path is provided
1504 if requestBody.Path == "" {
1505 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1506 return
1507 }
1508
1509 // Save file content using GitSaveFile
1510 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1511 if err != nil {
1512 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1513 return
1514 }
1515
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001516 // Auto-commit the changes
1517 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1518 if err != nil {
1519 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1520 return
1521 }
1522
1523 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001524 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1525 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1526 return
1527 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001528
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001529 // Return simple success response
1530 w.WriteHeader(http.StatusOK)
1531 w.Write([]byte("ok"))
1532}