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