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