blob: cc5ccc369e89bfc1809d550f6d295312da1b4b7f [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"`
84 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
85 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
86 SessionID string `json:"session_id"`
87 SSHAvailable bool `json:"ssh_available"`
88 SSHError string `json:"ssh_error,omitempty"`
89 InContainer bool `json:"in_container"`
90 FirstMessageIndex int `json:"first_message_index"`
91 AgentState string `json:"agent_state,omitempty"`
92 OutsideHostname string `json:"outside_hostname,omitempty"`
93 InsideHostname string `json:"inside_hostname,omitempty"`
94 OutsideOS string `json:"outside_os,omitempty"`
95 InsideOS string `json:"inside_os,omitempty"`
96 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
97 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070098 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
Philip Zeyliger0113be52025-06-07 23:53:41 +000099 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
Sean McCulloughd9f13372025-04-21 15:08:49 -0700100}
101
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700102type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700103 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
104 HostAddr string `json:"host_addr"`
105
106 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000107 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
108 SSHServerIdentity []byte `json:"ssh_server_identity"`
109 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
110 SSHHostCertificate []byte `json:"ssh_host_certificate"`
111 SSHAvailable bool `json:"ssh_available"`
112 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700113}
114
Earl Lee2e463fb2025-04-17 11:22:22 -0700115// Server serves sketch HTTP. Server implements http.Handler.
116type Server struct {
117 mux *http.ServeMux
118 agent loop.CodingAgent
119 hostname string
120 logFile *os.File
121 // Mutex to protect terminalSessions
122 ptyMutex sync.Mutex
123 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000124 sshAvailable bool
125 sshError string
Philip Zeyligerb5739402025-06-02 07:04:34 -0700126 // WaitGroup for clients waiting for end
127 endWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -0700128}
129
130func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
131 s.mux.ServeHTTP(w, r)
132}
133
134// New creates a new HTTP server.
135func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
136 s := &Server{
137 mux: http.NewServeMux(),
138 agent: agent,
139 hostname: getHostname(),
140 logFile: logFile,
141 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000142 sshAvailable: false,
143 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700144 }
145
146 webBundle, err := webui.Build()
147 if err != nil {
148 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
149 }
150
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000151 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000152
153 // Git tool endpoints
154 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
155 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700156 s.mux.HandleFunc("/git/cat", s.handleGitCat)
157 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000158 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
159
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
161 // Check if a specific commit hash was requested
162 commit := r.URL.Query().Get("commit")
163
164 // Get the diff, optionally for a specific commit
165 var diff string
166 var err error
167 if commit != "" {
168 // Validate the commit hash format
169 if !isValidGitSHA(commit) {
170 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
171 return
172 }
173
174 diff, err = agent.Diff(&commit)
175 } else {
176 diff, err = agent.Diff(nil)
177 }
178
179 if err != nil {
180 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
181 return
182 }
183
184 w.Header().Set("Content-Type", "text/plain")
185 w.Write([]byte(diff))
186 })
187
188 // Handler for initialization called by host sketch binary when inside docker.
189 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
190 defer func() {
191 if err := recover(); err != nil {
192 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
193
194 // Return an error response to the client
195 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
196 }
197 }()
198
199 if r.Method != "POST" {
200 http.Error(w, "POST required", http.StatusBadRequest)
201 return
202 }
203
204 body, err := io.ReadAll(r.Body)
205 r.Body.Close()
206 if err != nil {
207 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
208 return
209 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700210
211 m := &InitRequest{}
212 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
214 return
215 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700216
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000217 // Store SSH availability info
218 s.sshAvailable = m.SSHAvailable
219 s.sshError = m.SSHError
220
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700221 // Start the SSH server if the init request included ssh keys.
222 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
223 go func() {
224 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000225 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700226 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000227 // Update SSH error if server fails to start
228 s.sshAvailable = false
229 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700230 }
231 }()
232 }
233
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700235 InDocker: true,
236 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 }
238 if err := agent.Init(ini); err != nil {
239 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
240 return
241 }
242 w.Header().Set("Content-Type", "application/json")
243 io.WriteString(w, "{}\n")
244 })
245
Sean McCullough138ec242025-06-02 22:42:06 +0000246 // Handler for /port-events - returns recent port change events
247 s.mux.HandleFunc("/port-events", func(w http.ResponseWriter, r *http.Request) {
248 if r.Method != http.MethodGet {
249 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
250 return
251 }
252
253 w.Header().Set("Content-Type", "application/json")
254
255 // Get the 'since' query parameter for filtering events
256 sinceParam := r.URL.Query().Get("since")
257 var events []loop.PortEvent
258
259 // Get port monitor from agent
260 portMonitor := agent.GetPortMonitor()
261 if portMonitor == nil {
262 // Return empty array if port monitor not available
263 events = []loop.PortEvent{}
264 } else if sinceParam != "" {
265 // Parse the since timestamp
266 sinceTime, err := time.Parse(time.RFC3339, sinceParam)
267 if err != nil {
268 http.Error(w, fmt.Sprintf("Invalid 'since' timestamp format: %v", err), http.StatusBadRequest)
269 return
270 }
271 events = portMonitor.GetRecentEvents(sinceTime)
272 } else {
273 // Return all recent events
274 events = portMonitor.GetAllRecentEvents()
275 }
276
277 // Encode and return the events
278 if err := json.NewEncoder(w).Encode(events); err != nil {
279 slog.ErrorContext(r.Context(), "Error encoding port events response", slog.Any("err", err))
280 http.Error(w, "Internal server error", http.StatusInternalServerError)
281 }
282 })
283
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 // Handler for /messages?start=N&end=M (start/end are optional)
285 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
286 w.Header().Set("Content-Type", "application/json")
287
288 // Extract query parameters for range
289 var start, end int
290 var err error
291
292 currentCount := agent.MessageCount()
293
294 startParam := r.URL.Query().Get("start")
295 if startParam != "" {
296 start, err = strconv.Atoi(startParam)
297 if err != nil {
298 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
299 return
300 }
301 }
302
303 endParam := r.URL.Query().Get("end")
304 if endParam != "" {
305 end, err = strconv.Atoi(endParam)
306 if err != nil {
307 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
308 return
309 }
310 } else {
311 end = currentCount
312 }
313
314 if start < 0 || start > end || end > currentCount {
315 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
316 return
317 }
318
319 start = max(0, start)
320 end = min(agent.MessageCount(), end)
321 messages := agent.Messages(start, end)
322
323 // Create a JSON encoder with indentation for pretty-printing
324 encoder := json.NewEncoder(w)
325 encoder.SetIndent("", " ") // Two spaces for each indentation level
326
327 err = encoder.Encode(messages)
328 if err != nil {
329 http.Error(w, err.Error(), http.StatusInternalServerError)
330 }
331 })
332
333 // Handler for /logs - displays the contents of the log file
334 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
335 if s.logFile == nil {
336 http.Error(w, "log file not set", http.StatusNotFound)
337 return
338 }
339 logContents, err := os.ReadFile(s.logFile.Name())
340 if err != nil {
341 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
342 return
343 }
344 w.Header().Set("Content-Type", "text/html; charset=utf-8")
345 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
346 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
347 fmt.Fprintf(w, "</body>\n</html>")
348 })
349
350 // Handler for /download - downloads both messages and status as a JSON file
351 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
352 // Set headers for file download
353 w.Header().Set("Content-Type", "application/octet-stream")
354
355 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
356 timestamp := time.Now().Format("20060102-150405")
357 filename := fmt.Sprintf("sketch-%s.json", timestamp)
358
359 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
360
361 // Get all messages
362 messageCount := agent.MessageCount()
363 messages := agent.Messages(0, messageCount)
364
365 // Get status information (usage and other metadata)
366 totalUsage := agent.TotalUsage()
367 hostname := getHostname()
368 workingDir := getWorkingDir()
369
370 // Create a combined structure with all information
371 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700372 Messages []loop.AgentMessage `json:"messages"`
373 MessageCount int `json:"message_count"`
374 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
375 Hostname string `json:"hostname"`
376 WorkingDir string `json:"working_dir"`
377 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 }{
379 Messages: messages,
380 MessageCount: messageCount,
381 TotalUsage: totalUsage,
382 Hostname: hostname,
383 WorkingDir: workingDir,
384 DownloadTime: time.Now().Format(time.RFC3339),
385 }
386
387 // Marshal the JSON with indentation for better readability
388 jsonData, err := json.MarshalIndent(downloadData, "", " ")
389 if err != nil {
390 http.Error(w, err.Error(), http.StatusInternalServerError)
391 return
392 }
393 w.Write(jsonData)
394 })
395
396 // The latter doesn't return until the number of messages has changed (from seen
397 // or from when this was called.)
398 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
399 pollParam := r.URL.Query().Get("poll")
400 seenParam := r.URL.Query().Get("seen")
401
402 // Get the client's current message count (if provided)
403 clientMessageCount := -1
404 var err error
405 if seenParam != "" {
406 clientMessageCount, err = strconv.Atoi(seenParam)
407 if err != nil {
408 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
409 return
410 }
411 }
412
413 serverMessageCount := agent.MessageCount()
414
415 // Let lazy clients not have to specify this.
416 if clientMessageCount == -1 {
417 clientMessageCount = serverMessageCount
418 }
419
420 if pollParam == "true" {
421 ch := make(chan string)
422 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700423 it := agent.NewIterator(r.Context(), clientMessageCount)
424 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700426 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700427 }()
428 select {
429 case <-r.Context().Done():
430 slog.DebugContext(r.Context(), "abandoned poll request")
431 return
432 case <-time.After(90 * time.Second):
433 // Let the user call /state again to get the latest to limit how long our long polls hang out.
434 slog.DebugContext(r.Context(), "longish poll request")
435 break
436 case <-ch:
437 break
438 }
439 }
440
Earl Lee2e463fb2025-04-17 11:22:22 -0700441 w.Header().Set("Content-Type", "application/json")
442
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000443 // Use the shared getState function
444 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700445
446 // Create a JSON encoder with indentation for pretty-printing
447 encoder := json.NewEncoder(w)
448 encoder.SetIndent("", " ") // Two spaces for each indentation level
449
450 err = encoder.Encode(state)
451 if err != nil {
452 http.Error(w, err.Error(), http.StatusInternalServerError)
453 }
454 })
455
Philip Zeyliger176de792025-04-21 12:25:18 -0700456 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700457
458 // Terminal WebSocket handler
459 // Terminal endpoints - predefined terminals 1-9
460 // TODO: The UI doesn't actually know how to use terminals 2-9!
461 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
462 if r.Method != http.MethodGet {
463 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
464 return
465 }
466 pathParts := strings.Split(r.URL.Path, "/")
467 if len(pathParts) < 4 {
468 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
469 return
470 }
471
472 sessionID := pathParts[3]
473 // Validate that the terminal ID is between 1-9
474 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
475 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
476 return
477 }
478
479 s.handleTerminalEvents(w, r, sessionID)
480 })
481
482 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
483 if r.Method != http.MethodPost {
484 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
485 return
486 }
487 pathParts := strings.Split(r.URL.Path, "/")
488 if len(pathParts) < 4 {
489 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
490 return
491 }
492 sessionID := pathParts[3]
493 s.handleTerminalInput(w, r, sessionID)
494 })
495
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700496 // Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
Earl Lee2e463fb2025-04-17 11:22:22 -0700497 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700498 // Check URL parameters for interface selection
499 queryParams := r.URL.Query()
500
501 // Check if mobile interface is requested (?m parameter)
502 if queryParams.Has("m") {
503 // Serve the mobile-app-shell.html file
504 data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
505 if err != nil {
506 http.Error(w, "Mobile interface not found", http.StatusNotFound)
507 return
508 }
509 w.Header().Set("Content-Type", "text/html")
510 w.Write(data)
511 return
512 }
513
514 // Check if desktop interface is explicitly requested (?d parameter)
515 // or serve desktop by default
Sean McCullough86b56862025-04-18 13:04:03 -0700516 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700517 if err != nil {
518 http.Error(w, "File not found", http.StatusNotFound)
519 return
520 }
521 w.Header().Set("Content-Type", "text/html")
522 w.Write(data)
523 })
524
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700525 // Handler for /commit-description - returns the description of a git commit
526 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
527 if r.Method != http.MethodGet {
528 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
529 return
530 }
531
532 // Get the revision parameter
533 revision := r.URL.Query().Get("revision")
534 if revision == "" {
535 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
536 return
537 }
538
539 // Run git command to get commit description
540 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
541 // Use the working directory from the agent
542 cmd.Dir = s.agent.WorkingDir()
543
544 output, err := cmd.CombinedOutput()
545 if err != nil {
546 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
547 return
548 }
549
550 // Prepare the response
551 resp := map[string]string{
552 "description": strings.TrimSpace(string(output)),
553 }
554
555 w.Header().Set("Content-Type", "application/json")
556 if err := json.NewEncoder(w).Encode(resp); err != nil {
557 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
558 }
559 })
560
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000561 // Handler for /screenshot/{id} - serves screenshot images
562 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
563 if r.Method != http.MethodGet {
564 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
565 return
566 }
567
568 // Extract the screenshot ID from the path
569 pathParts := strings.Split(r.URL.Path, "/")
570 if len(pathParts) < 3 {
571 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
572 return
573 }
574
575 screenshotID := pathParts[2]
576
577 // Validate the ID format (prevent directory traversal)
578 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
579 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
580 return
581 }
582
583 // Get the screenshot file path
584 filePath := browse.GetScreenshotPath(screenshotID)
585
586 // Check if the file exists
587 if _, err := os.Stat(filePath); os.IsNotExist(err) {
588 http.Error(w, "Screenshot not found", http.StatusNotFound)
589 return
590 }
591
592 // Serve the file
593 w.Header().Set("Content-Type", "image/png")
594 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
595 http.ServeFile(w, r, filePath)
596 })
597
Earl Lee2e463fb2025-04-17 11:22:22 -0700598 // Handler for POST /chat
599 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
600 if r.Method != http.MethodPost {
601 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
602 return
603 }
604
605 // Parse the request body
606 var requestBody struct {
607 Message string `json:"message"`
608 }
609
610 decoder := json.NewDecoder(r.Body)
611 if err := decoder.Decode(&requestBody); err != nil {
612 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
613 return
614 }
615 defer r.Body.Close()
616
617 if requestBody.Message == "" {
618 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
619 return
620 }
621
622 agent.UserMessage(r.Context(), requestBody.Message)
623
624 w.WriteHeader(http.StatusOK)
625 })
626
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000627 // Handler for POST /upload - uploads a file to /tmp
628 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
629 if r.Method != http.MethodPost {
630 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
631 return
632 }
633
634 // Limit to 10MB file size
635 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
636
637 // Parse the multipart form
638 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
639 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
640 return
641 }
642
643 // Get the file from the multipart form
644 file, handler, err := r.FormFile("file")
645 if err != nil {
646 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
647 return
648 }
649 defer file.Close()
650
651 // Generate a unique ID (8 random bytes converted to 16 hex chars)
652 randBytes := make([]byte, 8)
653 if _, err := rand.Read(randBytes); err != nil {
654 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
655 return
656 }
657
658 // Get file extension from the original filename
659 ext := filepath.Ext(handler.Filename)
660
661 // Create a unique filename in the /tmp directory
662 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
663
664 // Create the destination file
665 destFile, err := os.Create(filename)
666 if err != nil {
667 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
668 return
669 }
670 defer destFile.Close()
671
672 // Copy the file contents to the destination file
673 if _, err := io.Copy(destFile, file); err != nil {
674 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
675 return
676 }
677
678 // Return the path to the saved file
679 w.Header().Set("Content-Type", "application/json")
680 json.NewEncoder(w).Encode(map[string]string{"path": filename})
681 })
682
Earl Lee2e463fb2025-04-17 11:22:22 -0700683 // Handler for /cancel - cancels the current inner loop in progress
684 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
685 if r.Method != http.MethodPost {
686 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
687 return
688 }
689
690 // Parse the request body (optional)
691 var requestBody struct {
692 Reason string `json:"reason"`
693 ToolCallID string `json:"tool_call_id"`
694 }
695
696 decoder := json.NewDecoder(r.Body)
697 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
698 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
699 return
700 }
701 defer r.Body.Close()
702
703 cancelReason := "user requested cancellation"
704 if requestBody.Reason != "" {
705 cancelReason = requestBody.Reason
706 }
707
708 if requestBody.ToolCallID != "" {
709 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
710 if err != nil {
711 http.Error(w, err.Error(), http.StatusBadRequest)
712 return
713 }
714 // Return a success response
715 w.Header().Set("Content-Type", "application/json")
716 json.NewEncoder(w).Encode(map[string]string{
717 "status": "cancelled",
718 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700719 "reason": cancelReason,
720 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700721 return
722 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000723 // Call the CancelTurn method
724 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700725 // Return a success response
726 w.Header().Set("Content-Type", "application/json")
727 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
728 })
729
Pokey Rule397871d2025-05-19 15:02:45 +0100730 // Handler for /end - shuts down the inner sketch process
731 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
732 if r.Method != http.MethodPost {
733 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
734 return
735 }
736
737 // Parse the request body (optional)
738 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700739 Reason string `json:"reason"`
740 Happy *bool `json:"happy,omitempty"`
741 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100742 }
743
744 decoder := json.NewDecoder(r.Body)
745 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
746 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
747 return
748 }
749 defer r.Body.Close()
750
751 endReason := "user requested end of session"
752 if requestBody.Reason != "" {
753 endReason = requestBody.Reason
754 }
755
756 // Send success response before exiting
757 w.Header().Set("Content-Type", "application/json")
758 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
759 if f, ok := w.(http.Flusher); ok {
760 f.Flush()
761 }
762
763 // Log that we're shutting down
764 slog.Info("Ending session", "reason", endReason)
765
Philip Zeyligerb5739402025-06-02 07:04:34 -0700766 // Wait for skaband clients that are waiting for end (with timeout)
Pokey Rule397871d2025-05-19 15:02:45 +0100767 go func() {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700768 startTime := time.Now()
769 // Wait up to 2 seconds for waiting clients to receive the end message
770 done := make(chan struct{})
771 go func() {
772 s.endWaitGroup.Wait()
773 close(done)
774 }()
775
776 select {
777 case <-done:
778 slog.Info("All waiting clients notified of end")
779 case <-time.After(2 * time.Second):
780 slog.Info("Timeout waiting for clients, proceeding with shutdown")
781 }
782
783 // Ensure we've been running for at least 100ms to allow response to be sent
784 elapsed := time.Since(startTime)
785 if elapsed < 100*time.Millisecond {
786 time.Sleep(100*time.Millisecond - elapsed)
787 }
788
Pokey Rule397871d2025-05-19 15:02:45 +0100789 os.Exit(0)
790 }()
791 })
792
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 debugMux := initDebugMux()
794 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
795 debugMux.ServeHTTP(w, r)
796 })
797
798 return s, nil
799}
800
801// Utility functions
802func getHostname() string {
803 hostname, err := os.Hostname()
804 if err != nil {
805 return "unknown"
806 }
807 return hostname
808}
809
810func getWorkingDir() string {
811 wd, err := os.Getwd()
812 if err != nil {
813 return "unknown"
814 }
815 return wd
816}
817
818// createTerminalSession creates a new terminal session with the given ID
819func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
820 // Start a new shell process
821 shellPath := getShellPath()
822 cmd := exec.Command(shellPath)
823
824 // Get working directory from the agent if possible
825 workDir := getWorkingDir()
826 cmd.Dir = workDir
827
828 // Set up environment
829 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
830
831 // Start the command with a pty
832 ptmx, err := pty.Start(cmd)
833 if err != nil {
834 slog.Error("Failed to start pty", "error", err)
835 return nil, err
836 }
837
838 // Create the terminal session
839 session := &terminalSession{
840 pty: ptmx,
841 eventsClients: make(map[chan []byte]bool),
842 cmd: cmd,
843 }
844
845 // Start goroutine to read from pty and broadcast to all connected SSE clients
846 go s.readFromPtyAndBroadcast(sessionID, session)
847
848 return session, nil
849} // handleTerminalEvents handles SSE connections for terminal output
850func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
851 // Check if the session exists, if not, create it
852 s.ptyMutex.Lock()
853 session, exists := s.terminalSessions[sessionID]
854
855 if !exists {
856 // Create a new terminal session
857 var err error
858 session, err = s.createTerminalSession(sessionID)
859 if err != nil {
860 s.ptyMutex.Unlock()
861 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
862 return
863 }
864
865 // Store the new session
866 s.terminalSessions[sessionID] = session
867 }
868 s.ptyMutex.Unlock()
869
870 // Set headers for SSE
871 w.Header().Set("Content-Type", "text/event-stream")
872 w.Header().Set("Cache-Control", "no-cache")
873 w.Header().Set("Connection", "keep-alive")
874 w.Header().Set("Access-Control-Allow-Origin", "*")
875
876 // Create a channel for this client
877 events := make(chan []byte, 4096) // Buffer to prevent blocking
878
879 // Register this client's channel
880 session.eventsClientsMutex.Lock()
881 clientID := session.lastEventClientID + 1
882 session.lastEventClientID = clientID
883 session.eventsClients[events] = true
884 session.eventsClientsMutex.Unlock()
885
886 // When the client disconnects, remove their channel
887 defer func() {
888 session.eventsClientsMutex.Lock()
889 delete(session.eventsClients, events)
890 close(events)
891 session.eventsClientsMutex.Unlock()
892 }()
893
894 // Flush to send headers to client immediately
895 if f, ok := w.(http.Flusher); ok {
896 f.Flush()
897 }
898
899 // Send events to the client as they arrive
900 for {
901 select {
902 case <-r.Context().Done():
903 return
904 case data := <-events:
905 // Format as SSE with base64 encoding
906 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
907
908 // Flush the data immediately
909 if f, ok := w.(http.Flusher); ok {
910 f.Flush()
911 }
912 }
913 }
914}
915
916// handleTerminalInput processes input to the terminal
917func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
918 // Check if the session exists
919 s.ptyMutex.Lock()
920 session, exists := s.terminalSessions[sessionID]
921 s.ptyMutex.Unlock()
922
923 if !exists {
924 http.Error(w, "Terminal session not found", http.StatusNotFound)
925 return
926 }
927
928 // Read the request body (terminal input or resize command)
929 body, err := io.ReadAll(r.Body)
930 if err != nil {
931 http.Error(w, "Failed to read request body", http.StatusBadRequest)
932 return
933 }
934
935 // Check if it's a resize message
936 if len(body) > 0 && body[0] == '{' {
937 var msg TerminalMessage
938 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
939 if msg.Cols > 0 && msg.Rows > 0 {
940 pty.Setsize(session.pty, &pty.Winsize{
941 Cols: msg.Cols,
942 Rows: msg.Rows,
943 })
944
945 // Respond with success
946 w.WriteHeader(http.StatusOK)
947 return
948 }
949 }
950 }
951
952 // Regular terminal input
953 _, err = session.pty.Write(body)
954 if err != nil {
955 slog.Error("Failed to write to pty", "error", err)
956 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
957 return
958 }
959
960 // Respond with success
961 w.WriteHeader(http.StatusOK)
962}
963
964// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
965func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
966 buf := make([]byte, 4096)
967 defer func() {
968 // Clean up when done
969 s.ptyMutex.Lock()
970 delete(s.terminalSessions, sessionID)
971 s.ptyMutex.Unlock()
972
973 // Close the PTY
974 session.pty.Close()
975
976 // Ensure process is terminated
977 if session.cmd.Process != nil {
978 session.cmd.Process.Signal(syscall.SIGTERM)
979 time.Sleep(100 * time.Millisecond)
980 session.cmd.Process.Kill()
981 }
982
983 // Close all client channels
984 session.eventsClientsMutex.Lock()
985 for ch := range session.eventsClients {
986 delete(session.eventsClients, ch)
987 close(ch)
988 }
989 session.eventsClientsMutex.Unlock()
990 }()
991
992 for {
993 n, err := session.pty.Read(buf)
994 if err != nil {
995 if err != io.EOF {
996 slog.Error("Failed to read from pty", "error", err)
997 }
998 break
999 }
1000
1001 // Make a copy of the data for each client
1002 data := make([]byte, n)
1003 copy(data, buf[:n])
1004
1005 // Broadcast to all connected clients
1006 session.eventsClientsMutex.Lock()
1007 for ch := range session.eventsClients {
1008 // Try to send, but don't block if channel is full
1009 select {
1010 case ch <- data:
1011 default:
1012 // Channel is full, drop the message for this client
1013 }
1014 }
1015 session.eventsClientsMutex.Unlock()
1016 }
1017}
1018
1019// getShellPath returns the path to the shell to use
1020func getShellPath() string {
1021 // Try to use the user's preferred shell
1022 shell := os.Getenv("SHELL")
1023 if shell != "" {
1024 return shell
1025 }
1026
1027 // Default to bash on Unix-like systems
1028 if _, err := os.Stat("/bin/bash"); err == nil {
1029 return "/bin/bash"
1030 }
1031
1032 // Fall back to sh
1033 return "/bin/sh"
1034}
1035
1036func initDebugMux() *http.ServeMux {
1037 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001038 build := "unknown build"
1039 bi, ok := debug.ReadBuildInfo()
1040 if ok {
1041 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1042 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1044 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001045 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001046 fmt.Fprintf(w, `<!doctype html>
1047 <html><head><title>sketch debug</title></head><body>
1048 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001049 pid %d<br>
1050 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001051 <ul>
1052 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
1053 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
1054 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
1055 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
1056 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1057 <li><a href="/debug/metrics">metrics</a></li>
1058 </ul>
1059 </body>
1060 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001061 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001062 })
1063 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1064 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1065 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1066 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1067 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
1068 return mux
1069}
1070
1071// isValidGitSHA validates if a string looks like a valid git SHA hash.
1072// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1073func isValidGitSHA(sha string) bool {
1074 // Git SHA must be a hexadecimal string with at least 4 characters
1075 if len(sha) < 4 || len(sha) > 40 {
1076 return false
1077 }
1078
1079 // Check if the string only contains hexadecimal characters
1080 for _, char := range sha {
1081 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1082 return false
1083 }
1084 }
1085
1086 return true
1087}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001088
1089// /stream?from=N endpoint for Server-Sent Events
1090func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1091 w.Header().Set("Content-Type", "text/event-stream")
1092 w.Header().Set("Cache-Control", "no-cache")
1093 w.Header().Set("Connection", "keep-alive")
1094 w.Header().Set("Access-Control-Allow-Origin", "*")
1095
1096 // Extract the 'from' parameter
1097 fromParam := r.URL.Query().Get("from")
1098 var fromIndex int
1099 var err error
1100 if fromParam != "" {
1101 fromIndex, err = strconv.Atoi(fromParam)
1102 if err != nil {
1103 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1104 return
1105 }
1106 }
1107
Philip Zeyligerb5739402025-06-02 07:04:34 -07001108 // Check if this client is waiting for end
1109 waitForEnd := r.URL.Query().Get("wait_for_end") == "true"
1110 if waitForEnd {
1111 s.endWaitGroup.Add(1)
1112 defer func() {
1113 if waitForEnd {
1114 s.endWaitGroup.Done()
1115 }
1116 }()
1117 }
1118
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001119 // Ensure 'from' is valid
1120 currentCount := s.agent.MessageCount()
1121 if fromIndex < 0 {
1122 fromIndex = 0
1123 } else if fromIndex > currentCount {
1124 fromIndex = currentCount
1125 }
1126
1127 // Send the current state immediately
1128 state := s.getState()
1129
1130 // Create JSON encoder
1131 encoder := json.NewEncoder(w)
1132
1133 // Send state as an event
1134 fmt.Fprintf(w, "event: state\n")
1135 fmt.Fprintf(w, "data: ")
1136 encoder.Encode(state)
1137 fmt.Fprintf(w, "\n\n")
1138
1139 if f, ok := w.(http.Flusher); ok {
1140 f.Flush()
1141 }
1142
1143 // Create a context for the SSE stream
1144 ctx := r.Context()
1145
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001146 // Setup heartbeat timer
1147 heartbeatTicker := time.NewTicker(45 * time.Second)
1148 defer heartbeatTicker.Stop()
1149
1150 // Create a channel for messages
1151 messageChan := make(chan *loop.AgentMessage, 10)
1152
Philip Zeyligereab12de2025-05-14 02:35:53 +00001153 // Create a channel for state transitions
1154 stateChan := make(chan *loop.StateTransition, 10)
1155
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001156 // Start a goroutine to read messages without blocking the heartbeat
1157 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001158 // Create an iterator to receive new messages as they arrive
1159 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1160 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001161 defer close(messageChan)
1162 for {
1163 // This can block, but it's in its own goroutine
1164 newMessage := iterator.Next()
1165 if newMessage == nil {
1166 // No message available (likely due to context cancellation)
1167 slog.InfoContext(ctx, "No more messages available, ending message stream")
1168 return
1169 }
1170
1171 select {
1172 case messageChan <- newMessage:
1173 // Message sent to channel
1174 case <-ctx.Done():
1175 // Context cancelled
1176 return
1177 }
1178 }
1179 }()
1180
Philip Zeyligereab12de2025-05-14 02:35:53 +00001181 // Start a goroutine to read state transitions
1182 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001183 // Create an iterator to receive state transitions
1184 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1185 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001186 defer close(stateChan)
1187 for {
1188 // This can block, but it's in its own goroutine
1189 newTransition := stateIterator.Next()
1190 if newTransition == nil {
1191 // No transition available (likely due to context cancellation)
1192 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1193 return
1194 }
1195
1196 select {
1197 case stateChan <- newTransition:
1198 // Transition sent to channel
1199 case <-ctx.Done():
1200 // Context cancelled
1201 return
1202 }
1203 }
1204 }()
1205
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001206 // Stay connected and stream real-time updates
1207 for {
1208 select {
1209 case <-heartbeatTicker.C:
1210 // Send heartbeat event
1211 fmt.Fprintf(w, "event: heartbeat\n")
1212 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1213
1214 // Flush to send the heartbeat immediately
1215 if f, ok := w.(http.Flusher); ok {
1216 f.Flush()
1217 }
1218
1219 case <-ctx.Done():
1220 // Client disconnected
1221 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1222 return
1223
Philip Zeyligereab12de2025-05-14 02:35:53 +00001224 case _, ok := <-stateChan:
1225 if !ok {
1226 // Channel closed
1227 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1228 return
1229 }
1230
1231 // Get updated state
1232 state = s.getState()
1233
1234 // Send updated state after the state transition
1235 fmt.Fprintf(w, "event: state\n")
1236 fmt.Fprintf(w, "data: ")
1237 encoder.Encode(state)
1238 fmt.Fprintf(w, "\n\n")
1239
1240 // Flush to send the state immediately
1241 if f, ok := w.(http.Flusher); ok {
1242 f.Flush()
1243 }
1244
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001245 case newMessage, ok := <-messageChan:
1246 if !ok {
1247 // Channel closed
1248 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1249 return
1250 }
1251
1252 // Send the new message as an event
1253 fmt.Fprintf(w, "event: message\n")
1254 fmt.Fprintf(w, "data: ")
1255 encoder.Encode(newMessage)
1256 fmt.Fprintf(w, "\n\n")
1257
1258 // Get updated state
1259 state = s.getState()
1260
1261 // Send updated state after the message
1262 fmt.Fprintf(w, "event: state\n")
1263 fmt.Fprintf(w, "data: ")
1264 encoder.Encode(state)
1265 fmt.Fprintf(w, "\n\n")
1266
1267 // Flush to send the message and state immediately
1268 if f, ok := w.(http.Flusher); ok {
1269 f.Flush()
1270 }
1271 }
1272 }
1273}
1274
1275// Helper function to get the current state
1276func (s *Server) getState() State {
1277 serverMessageCount := s.agent.MessageCount()
1278 totalUsage := s.agent.TotalUsage()
1279
1280 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001281 StateVersion: 2,
1282 MessageCount: serverMessageCount,
1283 TotalUsage: &totalUsage,
1284 Hostname: s.hostname,
1285 WorkingDir: getWorkingDir(),
1286 // TODO: Rename this field to sketch-base?
1287 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001288 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001289 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001290 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001291 OS: s.agent.OS(),
1292 OutsideHostname: s.agent.OutsideHostname(),
1293 InsideHostname: s.hostname,
1294 OutsideOS: s.agent.OutsideOS(),
1295 InsideOS: s.agent.OS(),
1296 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1297 InsideWorkingDir: getWorkingDir(),
1298 GitOrigin: s.agent.GitOrigin(),
1299 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1300 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1301 SessionID: s.agent.SessionID(),
1302 SSHAvailable: s.sshAvailable,
1303 SSHError: s.sshError,
1304 InContainer: s.agent.IsInContainer(),
1305 FirstMessageIndex: s.agent.FirstMessageIndex(),
1306 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001307 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001308 SkabandAddr: s.agent.SkabandAddr(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001309 }
1310}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001311
1312func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1313 if r.Method != "GET" {
1314 w.WriteHeader(http.StatusMethodNotAllowed)
1315 return
1316 }
1317
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001318 // Get the git repository root directory from agent
1319 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001320
1321 // Parse query parameters
1322 query := r.URL.Query()
1323 commit := query.Get("commit")
1324 from := query.Get("from")
1325 to := query.Get("to")
1326
1327 // If commit is specified, use commit^ and commit as from and to
1328 if commit != "" {
1329 from = commit + "^"
1330 to = commit
1331 }
1332
1333 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001334 if from == "" {
1335 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001336 return
1337 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001338 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001339
1340 // Call the git_tools function
1341 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1342 if err != nil {
1343 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1344 return
1345 }
1346
1347 // Return the result as JSON
1348 w.Header().Set("Content-Type", "application/json")
1349 if err := json.NewEncoder(w).Encode(diff); err != nil {
1350 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1351 return
1352 }
1353}
1354
1355func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1356 if r.Method != "GET" {
1357 w.WriteHeader(http.StatusMethodNotAllowed)
1358 return
1359 }
1360
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001361 // Get the git repository root directory from agent
1362 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001363
1364 // Parse query parameters
1365 hash := r.URL.Query().Get("hash")
1366 if hash == "" {
1367 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1368 return
1369 }
1370
1371 // Call the git_tools function
1372 show, err := git_tools.GitShow(repoDir, hash)
1373 if err != nil {
1374 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1375 return
1376 }
1377
1378 // Create a JSON response
1379 response := map[string]string{
1380 "hash": hash,
1381 "output": show,
1382 }
1383
1384 // Return the result as JSON
1385 w.Header().Set("Content-Type", "application/json")
1386 if err := json.NewEncoder(w).Encode(response); err != nil {
1387 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1388 return
1389 }
1390}
1391
1392func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1393 if r.Method != "GET" {
1394 w.WriteHeader(http.StatusMethodNotAllowed)
1395 return
1396 }
1397
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001398 // Get the git repository root directory and initial commit from agent
1399 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001400 initialCommit := s.agent.SketchGitBaseRef()
1401
1402 // Call the git_tools function
1403 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1404 if err != nil {
1405 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1406 return
1407 }
1408
1409 // Return the result as JSON
1410 w.Header().Set("Content-Type", "application/json")
1411 if err := json.NewEncoder(w).Encode(log); err != nil {
1412 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1413 return
1414 }
1415}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001416
1417func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1418 if r.Method != "GET" {
1419 w.WriteHeader(http.StatusMethodNotAllowed)
1420 return
1421 }
1422
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001423 // Get the git repository root directory from agent
1424 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001425
1426 // Parse query parameters
1427 query := r.URL.Query()
1428 path := query.Get("path")
1429
1430 // Check if path is provided
1431 if path == "" {
1432 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1433 return
1434 }
1435
1436 // Get file content using GitCat
1437 content, err := git_tools.GitCat(repoDir, path)
1438 if err != nil {
1439 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1440 return
1441 }
1442
1443 // Return the content as JSON for consistency with other endpoints
1444 w.Header().Set("Content-Type", "application/json")
1445 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1446 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1447 return
1448 }
1449}
1450
1451func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1452 if r.Method != "POST" {
1453 w.WriteHeader(http.StatusMethodNotAllowed)
1454 return
1455 }
1456
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001457 // Get the git repository root directory from agent
1458 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001459
1460 // Parse request body
1461 var requestBody struct {
1462 Path string `json:"path"`
1463 Content string `json:"content"`
1464 }
1465
1466 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1467 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1468 return
1469 }
1470 defer r.Body.Close()
1471
1472 // Check if path is provided
1473 if requestBody.Path == "" {
1474 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1475 return
1476 }
1477
1478 // Save file content using GitSaveFile
1479 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1480 if err != nil {
1481 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1482 return
1483 }
1484
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001485 // Auto-commit the changes
1486 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1487 if err != nil {
1488 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1489 return
1490 }
1491
1492 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001493 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1494 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1495 return
1496 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001497
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001498 // Return simple success response
1499 w.WriteHeader(http.StatusOK)
1500 w.Write([]byte("ok"))
1501}