blob: bef4982eb76c09c16acc223321ece140d3f7f424 [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"
16 "net/http/pprof"
17 "os"
18 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000019 "path/filepath"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070020 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "strconv"
22 "strings"
23 "sync"
24 "syscall"
25 "time"
26
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000027 "sketch.dev/git_tools"
Philip Zeyliger176de792025-04-21 12:25:18 -070028 "sketch.dev/loop/server/gzhandler"
29
Earl Lee2e463fb2025-04-17 11:22:22 -070030 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000031 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033 "sketch.dev/loop"
Philip Zeyliger2032b1c2025-04-23 19:40:42 -070034 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070035)
36
37// terminalSession represents a terminal session with its PTY and the event channel
38type terminalSession struct {
39 pty *os.File
40 eventsClients map[chan []byte]bool
41 lastEventClientID int
42 eventsClientsMutex sync.Mutex
43 cmd *exec.Cmd
44}
45
46// TerminalMessage represents a message sent from the client for terminal resize events
47type TerminalMessage struct {
48 Type string `json:"type"`
49 Cols uint16 `json:"cols"`
50 Rows uint16 `json:"rows"`
51}
52
53// TerminalResponse represents the response for a new terminal creation
54type TerminalResponse struct {
55 SessionID string `json:"sessionId"`
56}
57
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070058// TodoItem represents a single todo item for task management
59type TodoItem struct {
60 ID string `json:"id"`
61 Task string `json:"task"`
62 Status string `json:"status"` // queued, in-progress, completed
63}
64
65// TodoList represents a collection of todo items
66type TodoList struct {
67 Items []TodoItem `json:"items"`
68}
69
Sean McCulloughd9f13372025-04-21 15:08:49 -070070type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -070071 // null or 1: "old"
72 // 2: supports SSE for message updates
73 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070074 MessageCount int `json:"message_count"`
75 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
76 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070077 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070078 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000079 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070080 Hostname string `json:"hostname"` // deprecated
81 WorkingDir string `json:"working_dir"` // deprecated
82 OS string `json:"os"` // deprecated
83 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +000084 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
86 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
87 SessionID string `json:"session_id"`
88 SSHAvailable bool `json:"ssh_available"`
89 SSHError string `json:"ssh_error,omitempty"`
90 InContainer bool `json:"in_container"`
91 FirstMessageIndex int `json:"first_message_index"`
92 AgentState string `json:"agent_state,omitempty"`
93 OutsideHostname string `json:"outside_hostname,omitempty"`
94 InsideHostname string `json:"inside_hostname,omitempty"`
95 OutsideOS string `json:"outside_os,omitempty"`
96 InsideOS string `json:"inside_os,omitempty"`
97 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
98 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -070099 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
100 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
101 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
102 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700103 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
104 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Sean McCulloughd9f13372025-04-21 15:08:49 -0700105}
106
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700107type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700108 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
109 HostAddr string `json:"host_addr"`
110
111 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000112 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
113 SSHServerIdentity []byte `json:"ssh_server_identity"`
114 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
115 SSHHostCertificate []byte `json:"ssh_host_certificate"`
116 SSHAvailable bool `json:"ssh_available"`
117 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700118}
119
Earl Lee2e463fb2025-04-17 11:22:22 -0700120// Server serves sketch HTTP. Server implements http.Handler.
121type Server struct {
122 mux *http.ServeMux
123 agent loop.CodingAgent
124 hostname string
125 logFile *os.File
126 // Mutex to protect terminalSessions
127 ptyMutex sync.Mutex
128 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000129 sshAvailable bool
130 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700131}
132
133func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
134 s.mux.ServeHTTP(w, r)
135}
136
137// New creates a new HTTP server.
138func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
139 s := &Server{
140 mux: http.NewServeMux(),
141 agent: agent,
142 hostname: getHostname(),
143 logFile: logFile,
144 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000145 sshAvailable: false,
146 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700147 }
148
149 webBundle, err := webui.Build()
150 if err != nil {
151 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
152 }
153
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000154 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000155
156 // Git tool endpoints
157 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
158 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700159 s.mux.HandleFunc("/git/cat", s.handleGitCat)
160 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000161 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
162
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
164 // Check if a specific commit hash was requested
165 commit := r.URL.Query().Get("commit")
166
167 // Get the diff, optionally for a specific commit
168 var diff string
169 var err error
170 if commit != "" {
171 // Validate the commit hash format
172 if !isValidGitSHA(commit) {
173 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
174 return
175 }
176
177 diff, err = agent.Diff(&commit)
178 } else {
179 diff, err = agent.Diff(nil)
180 }
181
182 if err != nil {
183 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
184 return
185 }
186
187 w.Header().Set("Content-Type", "text/plain")
188 w.Write([]byte(diff))
189 })
190
191 // Handler for initialization called by host sketch binary when inside docker.
192 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
193 defer func() {
194 if err := recover(); err != nil {
195 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
196
197 // Return an error response to the client
198 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
199 }
200 }()
201
202 if r.Method != "POST" {
203 http.Error(w, "POST required", http.StatusBadRequest)
204 return
205 }
206
207 body, err := io.ReadAll(r.Body)
208 r.Body.Close()
209 if err != nil {
210 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
211 return
212 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700213
214 m := &InitRequest{}
215 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
217 return
218 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700219
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000220 // Store SSH availability info
221 s.sshAvailable = m.SSHAvailable
222 s.sshError = m.SSHError
223
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700224 // Start the SSH server if the init request included ssh keys.
225 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
226 go func() {
227 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000228 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700229 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000230 // Update SSH error if server fails to start
231 s.sshAvailable = false
232 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700233 }
234 }()
235 }
236
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700238 InDocker: true,
239 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 }
241 if err := agent.Init(ini); err != nil {
242 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
243 return
244 }
245 w.Header().Set("Content-Type", "application/json")
246 io.WriteString(w, "{}\n")
247 })
248
Sean McCullough138ec242025-06-02 22:42:06 +0000249 // Handler for /port-events - returns recent port change events
250 s.mux.HandleFunc("/port-events", func(w http.ResponseWriter, r *http.Request) {
251 if r.Method != http.MethodGet {
252 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
253 return
254 }
255
256 w.Header().Set("Content-Type", "application/json")
257
258 // Get the 'since' query parameter for filtering events
259 sinceParam := r.URL.Query().Get("since")
260 var events []loop.PortEvent
261
262 // Get port monitor from agent
263 portMonitor := agent.GetPortMonitor()
264 if portMonitor == nil {
265 // Return empty array if port monitor not available
266 events = []loop.PortEvent{}
267 } else if sinceParam != "" {
268 // Parse the since timestamp
269 sinceTime, err := time.Parse(time.RFC3339, sinceParam)
270 if err != nil {
271 http.Error(w, fmt.Sprintf("Invalid 'since' timestamp format: %v", err), http.StatusBadRequest)
272 return
273 }
274 events = portMonitor.GetRecentEvents(sinceTime)
275 } else {
276 // Return all recent events
277 events = portMonitor.GetAllRecentEvents()
278 }
279
280 // Encode and return the events
281 if err := json.NewEncoder(w).Encode(events); err != nil {
282 slog.ErrorContext(r.Context(), "Error encoding port events response", slog.Any("err", err))
283 http.Error(w, "Internal server error", http.StatusInternalServerError)
284 }
285 })
286
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 // Handler for /messages?start=N&end=M (start/end are optional)
288 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
289 w.Header().Set("Content-Type", "application/json")
290
291 // Extract query parameters for range
292 var start, end int
293 var err error
294
295 currentCount := agent.MessageCount()
296
297 startParam := r.URL.Query().Get("start")
298 if startParam != "" {
299 start, err = strconv.Atoi(startParam)
300 if err != nil {
301 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
302 return
303 }
304 }
305
306 endParam := r.URL.Query().Get("end")
307 if endParam != "" {
308 end, err = strconv.Atoi(endParam)
309 if err != nil {
310 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
311 return
312 }
313 } else {
314 end = currentCount
315 }
316
317 if start < 0 || start > end || end > currentCount {
318 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
319 return
320 }
321
322 start = max(0, start)
323 end = min(agent.MessageCount(), end)
324 messages := agent.Messages(start, end)
325
326 // Create a JSON encoder with indentation for pretty-printing
327 encoder := json.NewEncoder(w)
328 encoder.SetIndent("", " ") // Two spaces for each indentation level
329
330 err = encoder.Encode(messages)
331 if err != nil {
332 http.Error(w, err.Error(), http.StatusInternalServerError)
333 }
334 })
335
336 // Handler for /logs - displays the contents of the log file
337 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
338 if s.logFile == nil {
339 http.Error(w, "log file not set", http.StatusNotFound)
340 return
341 }
342 logContents, err := os.ReadFile(s.logFile.Name())
343 if err != nil {
344 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
345 return
346 }
347 w.Header().Set("Content-Type", "text/html; charset=utf-8")
348 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
349 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
350 fmt.Fprintf(w, "</body>\n</html>")
351 })
352
353 // Handler for /download - downloads both messages and status as a JSON file
354 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
355 // Set headers for file download
356 w.Header().Set("Content-Type", "application/octet-stream")
357
358 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
359 timestamp := time.Now().Format("20060102-150405")
360 filename := fmt.Sprintf("sketch-%s.json", timestamp)
361
362 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
363
364 // Get all messages
365 messageCount := agent.MessageCount()
366 messages := agent.Messages(0, messageCount)
367
368 // Get status information (usage and other metadata)
369 totalUsage := agent.TotalUsage()
370 hostname := getHostname()
371 workingDir := getWorkingDir()
372
373 // Create a combined structure with all information
374 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700375 Messages []loop.AgentMessage `json:"messages"`
376 MessageCount int `json:"message_count"`
377 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
378 Hostname string `json:"hostname"`
379 WorkingDir string `json:"working_dir"`
380 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700381 }{
382 Messages: messages,
383 MessageCount: messageCount,
384 TotalUsage: totalUsage,
385 Hostname: hostname,
386 WorkingDir: workingDir,
387 DownloadTime: time.Now().Format(time.RFC3339),
388 }
389
390 // Marshal the JSON with indentation for better readability
391 jsonData, err := json.MarshalIndent(downloadData, "", " ")
392 if err != nil {
393 http.Error(w, err.Error(), http.StatusInternalServerError)
394 return
395 }
396 w.Write(jsonData)
397 })
398
399 // The latter doesn't return until the number of messages has changed (from seen
400 // or from when this was called.)
401 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
402 pollParam := r.URL.Query().Get("poll")
403 seenParam := r.URL.Query().Get("seen")
404
405 // Get the client's current message count (if provided)
406 clientMessageCount := -1
407 var err error
408 if seenParam != "" {
409 clientMessageCount, err = strconv.Atoi(seenParam)
410 if err != nil {
411 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
412 return
413 }
414 }
415
416 serverMessageCount := agent.MessageCount()
417
418 // Let lazy clients not have to specify this.
419 if clientMessageCount == -1 {
420 clientMessageCount = serverMessageCount
421 }
422
423 if pollParam == "true" {
424 ch := make(chan string)
425 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700426 it := agent.NewIterator(r.Context(), clientMessageCount)
427 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700428 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700429 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700430 }()
431 select {
432 case <-r.Context().Done():
433 slog.DebugContext(r.Context(), "abandoned poll request")
434 return
435 case <-time.After(90 * time.Second):
436 // Let the user call /state again to get the latest to limit how long our long polls hang out.
437 slog.DebugContext(r.Context(), "longish poll request")
438 break
439 case <-ch:
440 break
441 }
442 }
443
Earl Lee2e463fb2025-04-17 11:22:22 -0700444 w.Header().Set("Content-Type", "application/json")
445
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000446 // Use the shared getState function
447 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700448
449 // Create a JSON encoder with indentation for pretty-printing
450 encoder := json.NewEncoder(w)
451 encoder.SetIndent("", " ") // Two spaces for each indentation level
452
453 err = encoder.Encode(state)
454 if err != nil {
455 http.Error(w, err.Error(), http.StatusInternalServerError)
456 }
457 })
458
Philip Zeyliger176de792025-04-21 12:25:18 -0700459 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700460
461 // Terminal WebSocket handler
462 // Terminal endpoints - predefined terminals 1-9
463 // TODO: The UI doesn't actually know how to use terminals 2-9!
464 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
465 if r.Method != http.MethodGet {
466 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
467 return
468 }
469 pathParts := strings.Split(r.URL.Path, "/")
470 if len(pathParts) < 4 {
471 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
472 return
473 }
474
475 sessionID := pathParts[3]
476 // Validate that the terminal ID is between 1-9
477 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
478 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
479 return
480 }
481
482 s.handleTerminalEvents(w, r, sessionID)
483 })
484
485 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
486 if r.Method != http.MethodPost {
487 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
488 return
489 }
490 pathParts := strings.Split(r.URL.Path, "/")
491 if len(pathParts) < 4 {
492 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
493 return
494 }
495 sessionID := pathParts[3]
496 s.handleTerminalInput(w, r, sessionID)
497 })
498
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700499 // Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
Earl Lee2e463fb2025-04-17 11:22:22 -0700500 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700501 // Check URL parameters for interface selection
502 queryParams := r.URL.Query()
503
504 // Check if mobile interface is requested (?m parameter)
505 if queryParams.Has("m") {
506 // Serve the mobile-app-shell.html file
507 data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
508 if err != nil {
509 http.Error(w, "Mobile interface not found", http.StatusNotFound)
510 return
511 }
512 w.Header().Set("Content-Type", "text/html")
513 w.Write(data)
514 return
515 }
516
517 // Check if desktop interface is explicitly requested (?d parameter)
518 // or serve desktop by default
Sean McCullough86b56862025-04-18 13:04:03 -0700519 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700520 if err != nil {
521 http.Error(w, "File not found", http.StatusNotFound)
522 return
523 }
524 w.Header().Set("Content-Type", "text/html")
525 w.Write(data)
526 })
527
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700528 // Handler for /commit-description - returns the description of a git commit
529 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
530 if r.Method != http.MethodGet {
531 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
532 return
533 }
534
535 // Get the revision parameter
536 revision := r.URL.Query().Get("revision")
537 if revision == "" {
538 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
539 return
540 }
541
542 // Run git command to get commit description
543 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
544 // Use the working directory from the agent
545 cmd.Dir = s.agent.WorkingDir()
546
547 output, err := cmd.CombinedOutput()
548 if err != nil {
549 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
550 return
551 }
552
553 // Prepare the response
554 resp := map[string]string{
555 "description": strings.TrimSpace(string(output)),
556 }
557
558 w.Header().Set("Content-Type", "application/json")
559 if err := json.NewEncoder(w).Encode(resp); err != nil {
560 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
561 }
562 })
563
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000564 // Handler for /screenshot/{id} - serves screenshot images
565 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
566 if r.Method != http.MethodGet {
567 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
568 return
569 }
570
571 // Extract the screenshot ID from the path
572 pathParts := strings.Split(r.URL.Path, "/")
573 if len(pathParts) < 3 {
574 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
575 return
576 }
577
578 screenshotID := pathParts[2]
579
580 // Validate the ID format (prevent directory traversal)
581 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
582 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
583 return
584 }
585
586 // Get the screenshot file path
587 filePath := browse.GetScreenshotPath(screenshotID)
588
589 // Check if the file exists
590 if _, err := os.Stat(filePath); os.IsNotExist(err) {
591 http.Error(w, "Screenshot not found", http.StatusNotFound)
592 return
593 }
594
595 // Serve the file
596 w.Header().Set("Content-Type", "image/png")
597 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
598 http.ServeFile(w, r, filePath)
599 })
600
Earl Lee2e463fb2025-04-17 11:22:22 -0700601 // Handler for POST /chat
602 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
603 if r.Method != http.MethodPost {
604 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
605 return
606 }
607
608 // Parse the request body
609 var requestBody struct {
610 Message string `json:"message"`
611 }
612
613 decoder := json.NewDecoder(r.Body)
614 if err := decoder.Decode(&requestBody); err != nil {
615 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
616 return
617 }
618 defer r.Body.Close()
619
620 if requestBody.Message == "" {
621 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
622 return
623 }
624
625 agent.UserMessage(r.Context(), requestBody.Message)
626
627 w.WriteHeader(http.StatusOK)
628 })
629
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000630 // Handler for POST /upload - uploads a file to /tmp
631 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
632 if r.Method != http.MethodPost {
633 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
634 return
635 }
636
637 // Limit to 10MB file size
638 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
639
640 // Parse the multipart form
641 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
642 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
643 return
644 }
645
646 // Get the file from the multipart form
647 file, handler, err := r.FormFile("file")
648 if err != nil {
649 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
650 return
651 }
652 defer file.Close()
653
654 // Generate a unique ID (8 random bytes converted to 16 hex chars)
655 randBytes := make([]byte, 8)
656 if _, err := rand.Read(randBytes); err != nil {
657 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
658 return
659 }
660
661 // Get file extension from the original filename
662 ext := filepath.Ext(handler.Filename)
663
664 // Create a unique filename in the /tmp directory
665 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
666
667 // Create the destination file
668 destFile, err := os.Create(filename)
669 if err != nil {
670 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
671 return
672 }
673 defer destFile.Close()
674
675 // Copy the file contents to the destination file
676 if _, err := io.Copy(destFile, file); err != nil {
677 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
678 return
679 }
680
681 // Return the path to the saved file
682 w.Header().Set("Content-Type", "application/json")
683 json.NewEncoder(w).Encode(map[string]string{"path": filename})
684 })
685
Earl Lee2e463fb2025-04-17 11:22:22 -0700686 // Handler for /cancel - cancels the current inner loop in progress
687 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
688 if r.Method != http.MethodPost {
689 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
690 return
691 }
692
693 // Parse the request body (optional)
694 var requestBody struct {
695 Reason string `json:"reason"`
696 ToolCallID string `json:"tool_call_id"`
697 }
698
699 decoder := json.NewDecoder(r.Body)
700 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
701 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
702 return
703 }
704 defer r.Body.Close()
705
706 cancelReason := "user requested cancellation"
707 if requestBody.Reason != "" {
708 cancelReason = requestBody.Reason
709 }
710
711 if requestBody.ToolCallID != "" {
712 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
713 if err != nil {
714 http.Error(w, err.Error(), http.StatusBadRequest)
715 return
716 }
717 // Return a success response
718 w.Header().Set("Content-Type", "application/json")
719 json.NewEncoder(w).Encode(map[string]string{
720 "status": "cancelled",
721 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700722 "reason": cancelReason,
723 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700724 return
725 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000726 // Call the CancelTurn method
727 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 // Return a success response
729 w.Header().Set("Content-Type", "application/json")
730 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
731 })
732
Pokey Rule397871d2025-05-19 15:02:45 +0100733 // Handler for /end - shuts down the inner sketch process
734 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
735 if r.Method != http.MethodPost {
736 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
737 return
738 }
739
740 // Parse the request body (optional)
741 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700742 Reason string `json:"reason"`
743 Happy *bool `json:"happy,omitempty"`
744 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100745 }
746
747 decoder := json.NewDecoder(r.Body)
748 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
749 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
750 return
751 }
752 defer r.Body.Close()
753
754 endReason := "user requested end of session"
755 if requestBody.Reason != "" {
756 endReason = requestBody.Reason
757 }
758
759 // Send success response before exiting
760 w.Header().Set("Content-Type", "application/json")
761 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
762 if f, ok := w.(http.Flusher); ok {
763 f.Flush()
764 }
765
766 // Log that we're shutting down
767 slog.Info("Ending session", "reason", endReason)
768
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000769 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100770 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000771 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100772 os.Exit(0)
773 }()
774 })
775
Earl Lee2e463fb2025-04-17 11:22:22 -0700776 debugMux := initDebugMux()
777 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
778 debugMux.ServeHTTP(w, r)
779 })
780
781 return s, nil
782}
783
784// Utility functions
785func getHostname() string {
786 hostname, err := os.Hostname()
787 if err != nil {
788 return "unknown"
789 }
790 return hostname
791}
792
793func getWorkingDir() string {
794 wd, err := os.Getwd()
795 if err != nil {
796 return "unknown"
797 }
798 return wd
799}
800
801// createTerminalSession creates a new terminal session with the given ID
802func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
803 // Start a new shell process
804 shellPath := getShellPath()
805 cmd := exec.Command(shellPath)
806
807 // Get working directory from the agent if possible
808 workDir := getWorkingDir()
809 cmd.Dir = workDir
810
811 // Set up environment
812 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
813
814 // Start the command with a pty
815 ptmx, err := pty.Start(cmd)
816 if err != nil {
817 slog.Error("Failed to start pty", "error", err)
818 return nil, err
819 }
820
821 // Create the terminal session
822 session := &terminalSession{
823 pty: ptmx,
824 eventsClients: make(map[chan []byte]bool),
825 cmd: cmd,
826 }
827
828 // Start goroutine to read from pty and broadcast to all connected SSE clients
829 go s.readFromPtyAndBroadcast(sessionID, session)
830
831 return session, nil
832} // handleTerminalEvents handles SSE connections for terminal output
833func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
834 // Check if the session exists, if not, create it
835 s.ptyMutex.Lock()
836 session, exists := s.terminalSessions[sessionID]
837
838 if !exists {
839 // Create a new terminal session
840 var err error
841 session, err = s.createTerminalSession(sessionID)
842 if err != nil {
843 s.ptyMutex.Unlock()
844 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
845 return
846 }
847
848 // Store the new session
849 s.terminalSessions[sessionID] = session
850 }
851 s.ptyMutex.Unlock()
852
853 // Set headers for SSE
854 w.Header().Set("Content-Type", "text/event-stream")
855 w.Header().Set("Cache-Control", "no-cache")
856 w.Header().Set("Connection", "keep-alive")
857 w.Header().Set("Access-Control-Allow-Origin", "*")
858
859 // Create a channel for this client
860 events := make(chan []byte, 4096) // Buffer to prevent blocking
861
862 // Register this client's channel
863 session.eventsClientsMutex.Lock()
864 clientID := session.lastEventClientID + 1
865 session.lastEventClientID = clientID
866 session.eventsClients[events] = true
867 session.eventsClientsMutex.Unlock()
868
869 // When the client disconnects, remove their channel
870 defer func() {
871 session.eventsClientsMutex.Lock()
872 delete(session.eventsClients, events)
873 close(events)
874 session.eventsClientsMutex.Unlock()
875 }()
876
877 // Flush to send headers to client immediately
878 if f, ok := w.(http.Flusher); ok {
879 f.Flush()
880 }
881
882 // Send events to the client as they arrive
883 for {
884 select {
885 case <-r.Context().Done():
886 return
887 case data := <-events:
888 // Format as SSE with base64 encoding
889 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
890
891 // Flush the data immediately
892 if f, ok := w.(http.Flusher); ok {
893 f.Flush()
894 }
895 }
896 }
897}
898
899// handleTerminalInput processes input to the terminal
900func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
901 // Check if the session exists
902 s.ptyMutex.Lock()
903 session, exists := s.terminalSessions[sessionID]
904 s.ptyMutex.Unlock()
905
906 if !exists {
907 http.Error(w, "Terminal session not found", http.StatusNotFound)
908 return
909 }
910
911 // Read the request body (terminal input or resize command)
912 body, err := io.ReadAll(r.Body)
913 if err != nil {
914 http.Error(w, "Failed to read request body", http.StatusBadRequest)
915 return
916 }
917
918 // Check if it's a resize message
919 if len(body) > 0 && body[0] == '{' {
920 var msg TerminalMessage
921 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
922 if msg.Cols > 0 && msg.Rows > 0 {
923 pty.Setsize(session.pty, &pty.Winsize{
924 Cols: msg.Cols,
925 Rows: msg.Rows,
926 })
927
928 // Respond with success
929 w.WriteHeader(http.StatusOK)
930 return
931 }
932 }
933 }
934
935 // Regular terminal input
936 _, err = session.pty.Write(body)
937 if err != nil {
938 slog.Error("Failed to write to pty", "error", err)
939 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
940 return
941 }
942
943 // Respond with success
944 w.WriteHeader(http.StatusOK)
945}
946
947// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
948func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
949 buf := make([]byte, 4096)
950 defer func() {
951 // Clean up when done
952 s.ptyMutex.Lock()
953 delete(s.terminalSessions, sessionID)
954 s.ptyMutex.Unlock()
955
956 // Close the PTY
957 session.pty.Close()
958
959 // Ensure process is terminated
960 if session.cmd.Process != nil {
961 session.cmd.Process.Signal(syscall.SIGTERM)
962 time.Sleep(100 * time.Millisecond)
963 session.cmd.Process.Kill()
964 }
965
966 // Close all client channels
967 session.eventsClientsMutex.Lock()
968 for ch := range session.eventsClients {
969 delete(session.eventsClients, ch)
970 close(ch)
971 }
972 session.eventsClientsMutex.Unlock()
973 }()
974
975 for {
976 n, err := session.pty.Read(buf)
977 if err != nil {
978 if err != io.EOF {
979 slog.Error("Failed to read from pty", "error", err)
980 }
981 break
982 }
983
984 // Make a copy of the data for each client
985 data := make([]byte, n)
986 copy(data, buf[:n])
987
988 // Broadcast to all connected clients
989 session.eventsClientsMutex.Lock()
990 for ch := range session.eventsClients {
991 // Try to send, but don't block if channel is full
992 select {
993 case ch <- data:
994 default:
995 // Channel is full, drop the message for this client
996 }
997 }
998 session.eventsClientsMutex.Unlock()
999 }
1000}
1001
1002// getShellPath returns the path to the shell to use
1003func getShellPath() string {
1004 // Try to use the user's preferred shell
1005 shell := os.Getenv("SHELL")
1006 if shell != "" {
1007 return shell
1008 }
1009
1010 // Default to bash on Unix-like systems
1011 if _, err := os.Stat("/bin/bash"); err == nil {
1012 return "/bin/bash"
1013 }
1014
1015 // Fall back to sh
1016 return "/bin/sh"
1017}
1018
1019func initDebugMux() *http.ServeMux {
1020 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001021 build := "unknown build"
1022 bi, ok := debug.ReadBuildInfo()
1023 if ok {
1024 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1025 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001026 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1027 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001028 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001029 fmt.Fprintf(w, `<!doctype html>
1030 <html><head><title>sketch debug</title></head><body>
1031 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001032 pid %d<br>
1033 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001034 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001035 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1036 <li><a href="pprof/profile">pprof/profile</a></li>
1037 <li><a href="pprof/symbol">pprof/symbol</a></li>
1038 <li><a href="pprof/trace">pprof/trace</a></li>
1039 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001040 </ul>
1041 </body>
1042 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001043 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001044 })
1045 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1046 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1047 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1048 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1049 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1050 return mux
1051}
1052
1053// isValidGitSHA validates if a string looks like a valid git SHA hash.
1054// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1055func isValidGitSHA(sha string) bool {
1056 // Git SHA must be a hexadecimal string with at least 4 characters
1057 if len(sha) < 4 || len(sha) > 40 {
1058 return false
1059 }
1060
1061 // Check if the string only contains hexadecimal characters
1062 for _, char := range sha {
1063 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1064 return false
1065 }
1066 }
1067
1068 return true
1069}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001070
1071// /stream?from=N endpoint for Server-Sent Events
1072func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1073 w.Header().Set("Content-Type", "text/event-stream")
1074 w.Header().Set("Cache-Control", "no-cache")
1075 w.Header().Set("Connection", "keep-alive")
1076 w.Header().Set("Access-Control-Allow-Origin", "*")
1077
1078 // Extract the 'from' parameter
1079 fromParam := r.URL.Query().Get("from")
1080 var fromIndex int
1081 var err error
1082 if fromParam != "" {
1083 fromIndex, err = strconv.Atoi(fromParam)
1084 if err != nil {
1085 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1086 return
1087 }
1088 }
1089
1090 // Ensure 'from' is valid
1091 currentCount := s.agent.MessageCount()
1092 if fromIndex < 0 {
1093 fromIndex = 0
1094 } else if fromIndex > currentCount {
1095 fromIndex = currentCount
1096 }
1097
1098 // Send the current state immediately
1099 state := s.getState()
1100
1101 // Create JSON encoder
1102 encoder := json.NewEncoder(w)
1103
1104 // Send state as an event
1105 fmt.Fprintf(w, "event: state\n")
1106 fmt.Fprintf(w, "data: ")
1107 encoder.Encode(state)
1108 fmt.Fprintf(w, "\n\n")
1109
1110 if f, ok := w.(http.Flusher); ok {
1111 f.Flush()
1112 }
1113
1114 // Create a context for the SSE stream
1115 ctx := r.Context()
1116
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001117 // Setup heartbeat timer
1118 heartbeatTicker := time.NewTicker(45 * time.Second)
1119 defer heartbeatTicker.Stop()
1120
1121 // Create a channel for messages
1122 messageChan := make(chan *loop.AgentMessage, 10)
1123
Philip Zeyligereab12de2025-05-14 02:35:53 +00001124 // Create a channel for state transitions
1125 stateChan := make(chan *loop.StateTransition, 10)
1126
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001127 // Start a goroutine to read messages without blocking the heartbeat
1128 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001129 // Create an iterator to receive new messages as they arrive
1130 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1131 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001132 defer close(messageChan)
1133 for {
1134 // This can block, but it's in its own goroutine
1135 newMessage := iterator.Next()
1136 if newMessage == nil {
1137 // No message available (likely due to context cancellation)
1138 slog.InfoContext(ctx, "No more messages available, ending message stream")
1139 return
1140 }
1141
1142 select {
1143 case messageChan <- newMessage:
1144 // Message sent to channel
1145 case <-ctx.Done():
1146 // Context cancelled
1147 return
1148 }
1149 }
1150 }()
1151
Philip Zeyligereab12de2025-05-14 02:35:53 +00001152 // Start a goroutine to read state transitions
1153 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001154 // Create an iterator to receive state transitions
1155 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1156 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001157 defer close(stateChan)
1158 for {
1159 // This can block, but it's in its own goroutine
1160 newTransition := stateIterator.Next()
1161 if newTransition == nil {
1162 // No transition available (likely due to context cancellation)
1163 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1164 return
1165 }
1166
1167 select {
1168 case stateChan <- newTransition:
1169 // Transition sent to channel
1170 case <-ctx.Done():
1171 // Context cancelled
1172 return
1173 }
1174 }
1175 }()
1176
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001177 // Stay connected and stream real-time updates
1178 for {
1179 select {
1180 case <-heartbeatTicker.C:
1181 // Send heartbeat event
1182 fmt.Fprintf(w, "event: heartbeat\n")
1183 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1184
1185 // Flush to send the heartbeat immediately
1186 if f, ok := w.(http.Flusher); ok {
1187 f.Flush()
1188 }
1189
1190 case <-ctx.Done():
1191 // Client disconnected
1192 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1193 return
1194
Philip Zeyligereab12de2025-05-14 02:35:53 +00001195 case _, ok := <-stateChan:
1196 if !ok {
1197 // Channel closed
1198 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1199 return
1200 }
1201
1202 // Get updated state
1203 state = s.getState()
1204
1205 // Send updated state after the state transition
1206 fmt.Fprintf(w, "event: state\n")
1207 fmt.Fprintf(w, "data: ")
1208 encoder.Encode(state)
1209 fmt.Fprintf(w, "\n\n")
1210
1211 // Flush to send the state immediately
1212 if f, ok := w.(http.Flusher); ok {
1213 f.Flush()
1214 }
1215
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001216 case newMessage, ok := <-messageChan:
1217 if !ok {
1218 // Channel closed
1219 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1220 return
1221 }
1222
1223 // Send the new message as an event
1224 fmt.Fprintf(w, "event: message\n")
1225 fmt.Fprintf(w, "data: ")
1226 encoder.Encode(newMessage)
1227 fmt.Fprintf(w, "\n\n")
1228
1229 // Get updated state
1230 state = s.getState()
1231
1232 // Send updated state after the message
1233 fmt.Fprintf(w, "event: state\n")
1234 fmt.Fprintf(w, "data: ")
1235 encoder.Encode(state)
1236 fmt.Fprintf(w, "\n\n")
1237
1238 // Flush to send the message and state immediately
1239 if f, ok := w.(http.Flusher); ok {
1240 f.Flush()
1241 }
1242 }
1243 }
1244}
1245
1246// Helper function to get the current state
1247func (s *Server) getState() State {
1248 serverMessageCount := s.agent.MessageCount()
1249 totalUsage := s.agent.TotalUsage()
1250
Philip Zeyliger64f60462025-06-16 13:57:10 -07001251 // Get diff stats
1252 diffAdded, diffRemoved := s.agent.DiffStats()
1253
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001254 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001255 StateVersion: 2,
1256 MessageCount: serverMessageCount,
1257 TotalUsage: &totalUsage,
1258 Hostname: s.hostname,
1259 WorkingDir: getWorkingDir(),
1260 // TODO: Rename this field to sketch-base?
1261 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001262 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001263 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001264 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001265 OS: s.agent.OS(),
1266 OutsideHostname: s.agent.OutsideHostname(),
1267 InsideHostname: s.hostname,
1268 OutsideOS: s.agent.OutsideOS(),
1269 InsideOS: s.agent.OS(),
1270 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1271 InsideWorkingDir: getWorkingDir(),
1272 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001273 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001274 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1275 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1276 SessionID: s.agent.SessionID(),
1277 SSHAvailable: s.sshAvailable,
1278 SSHError: s.sshError,
1279 InContainer: s.agent.IsInContainer(),
1280 FirstMessageIndex: s.agent.FirstMessageIndex(),
1281 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001282 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001283 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001284 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001285 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001286 DiffLinesAdded: diffAdded,
1287 DiffLinesRemoved: diffRemoved,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001288 }
1289}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001290
1291func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1292 if r.Method != "GET" {
1293 w.WriteHeader(http.StatusMethodNotAllowed)
1294 return
1295 }
1296
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001297 // Get the git repository root directory from agent
1298 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001299
1300 // Parse query parameters
1301 query := r.URL.Query()
1302 commit := query.Get("commit")
1303 from := query.Get("from")
1304 to := query.Get("to")
1305
1306 // If commit is specified, use commit^ and commit as from and to
1307 if commit != "" {
1308 from = commit + "^"
1309 to = commit
1310 }
1311
1312 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001313 if from == "" {
1314 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001315 return
1316 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001317 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001318
1319 // Call the git_tools function
1320 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1321 if err != nil {
1322 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1323 return
1324 }
1325
1326 // Return the result as JSON
1327 w.Header().Set("Content-Type", "application/json")
1328 if err := json.NewEncoder(w).Encode(diff); err != nil {
1329 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1330 return
1331 }
1332}
1333
1334func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1335 if r.Method != "GET" {
1336 w.WriteHeader(http.StatusMethodNotAllowed)
1337 return
1338 }
1339
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001340 // Get the git repository root directory from agent
1341 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001342
1343 // Parse query parameters
1344 hash := r.URL.Query().Get("hash")
1345 if hash == "" {
1346 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1347 return
1348 }
1349
1350 // Call the git_tools function
1351 show, err := git_tools.GitShow(repoDir, hash)
1352 if err != nil {
1353 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1354 return
1355 }
1356
1357 // Create a JSON response
1358 response := map[string]string{
1359 "hash": hash,
1360 "output": show,
1361 }
1362
1363 // Return the result as JSON
1364 w.Header().Set("Content-Type", "application/json")
1365 if err := json.NewEncoder(w).Encode(response); err != nil {
1366 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1367 return
1368 }
1369}
1370
1371func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1372 if r.Method != "GET" {
1373 w.WriteHeader(http.StatusMethodNotAllowed)
1374 return
1375 }
1376
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001377 // Get the git repository root directory and initial commit from agent
1378 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001379 initialCommit := s.agent.SketchGitBaseRef()
1380
1381 // Call the git_tools function
1382 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1383 if err != nil {
1384 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1385 return
1386 }
1387
1388 // Return the result as JSON
1389 w.Header().Set("Content-Type", "application/json")
1390 if err := json.NewEncoder(w).Encode(log); err != nil {
1391 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1392 return
1393 }
1394}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001395
1396func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1397 if r.Method != "GET" {
1398 w.WriteHeader(http.StatusMethodNotAllowed)
1399 return
1400 }
1401
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001402 // Get the git repository root directory from agent
1403 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001404
1405 // Parse query parameters
1406 query := r.URL.Query()
1407 path := query.Get("path")
1408
1409 // Check if path is provided
1410 if path == "" {
1411 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1412 return
1413 }
1414
1415 // Get file content using GitCat
1416 content, err := git_tools.GitCat(repoDir, path)
1417 if err != nil {
1418 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1419 return
1420 }
1421
1422 // Return the content as JSON for consistency with other endpoints
1423 w.Header().Set("Content-Type", "application/json")
1424 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1425 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1426 return
1427 }
1428}
1429
1430func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1431 if r.Method != "POST" {
1432 w.WriteHeader(http.StatusMethodNotAllowed)
1433 return
1434 }
1435
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001436 // Get the git repository root directory from agent
1437 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001438
1439 // Parse request body
1440 var requestBody struct {
1441 Path string `json:"path"`
1442 Content string `json:"content"`
1443 }
1444
1445 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1446 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1447 return
1448 }
1449 defer r.Body.Close()
1450
1451 // Check if path is provided
1452 if requestBody.Path == "" {
1453 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1454 return
1455 }
1456
1457 // Save file content using GitSaveFile
1458 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1459 if err != nil {
1460 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1461 return
1462 }
1463
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001464 // Auto-commit the changes
1465 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1466 if err != nil {
1467 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1468 return
1469 }
1470
1471 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001472 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1473 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1474 return
1475 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001476
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001477 // Return simple success response
1478 w.WriteHeader(http.StatusOK)
1479 w.Write([]byte("ok"))
1480}