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