blob: 296317b31ed25573d37f827d28e8ca34e737c5b6 [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"
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +000010 "errors"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "fmt"
12 "html"
13 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "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 Zeyliger254c49f2025-07-17 17:26:24 -070022 "regexp"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070023 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070024 "strconv"
25 "strings"
26 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070027 "time"
28
29 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000030 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070031 "sketch.dev/embedded"
32 "sketch.dev/git_tools"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070033 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070034 "sketch.dev/loop"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070035 "sketch.dev/loop/server/gzhandler"
Earl Lee2e463fb2025-04-17 11:22:22 -070036)
37
Philip Zeyliger254c49f2025-07-17 17:26:24 -070038// Remote represents a git remote with display information.
39type Remote struct {
40 Name string `json:"name"`
41 URL string `json:"url"`
42 DisplayName string `json:"display_name"`
43 IsGitHub bool `json:"is_github"`
44}
45
46// GitPushInfoResponse represents the response from /git/pushinfo
47type GitPushInfoResponse struct {
48 Hash string `json:"hash"`
49 Subject string `json:"subject"`
50 Remotes []Remote `json:"remotes"`
51}
52
53// GitPushRequest represents the request body for /git/push
54type GitPushRequest struct {
55 Remote string `json:"remote"`
56 Branch string `json:"branch"`
57 Commit string `json:"commit"`
58 DryRun bool `json:"dry_run"`
59 Force bool `json:"force"`
60}
61
62// GitPushResponse represents the response from /git/push
63type GitPushResponse struct {
64 Success bool `json:"success"`
65 Output string `json:"output"`
66 DryRun bool `json:"dry_run"`
67 Error string `json:"error,omitempty"`
68}
69
70// isGitHubURL checks if a URL is a GitHub URL
71func isGitHubURL(url string) bool {
72 return strings.Contains(url, "github.com")
73}
74
75// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
76// and also returns whether it's a github url
77func simplifyGitHubURL(url string) (string, bool) {
78 // Handle GitHub URLs in various formats
79 if strings.Contains(url, "github.com") {
80 // Extract owner/repo from URLs like:
81 // https://github.com/owner/repo.git
82 // git@github.com:owner/repo.git
83 // https://github.com/owner/repo
84 re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
85 if matches := re.FindStringSubmatch(url); len(matches) > 1 {
86 return matches[1], true
87 }
88 }
89 return url, false
90}
91
Earl Lee2e463fb2025-04-17 11:22:22 -070092// terminalSession represents a terminal session with its PTY and the event channel
93type terminalSession struct {
94 pty *os.File
95 eventsClients map[chan []byte]bool
96 lastEventClientID int
97 eventsClientsMutex sync.Mutex
98 cmd *exec.Cmd
99}
100
101// TerminalMessage represents a message sent from the client for terminal resize events
102type TerminalMessage struct {
103 Type string `json:"type"`
104 Cols uint16 `json:"cols"`
105 Rows uint16 `json:"rows"`
106}
107
108// TerminalResponse represents the response for a new terminal creation
109type TerminalResponse struct {
110 SessionID string `json:"sessionId"`
111}
112
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700113// TodoItem represents a single todo item for task management
114type TodoItem struct {
115 ID string `json:"id"`
116 Task string `json:"task"`
117 Status string `json:"status"` // queued, in-progress, completed
118}
119
120// TodoList represents a collection of todo items
121type TodoList struct {
122 Items []TodoItem `json:"items"`
123}
124
Sean McCulloughd9f13372025-04-21 15:08:49 -0700125type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700126 // null or 1: "old"
127 // 2: supports SSE for message updates
128 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700129 MessageCount int `json:"message_count"`
130 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
131 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700132 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700133 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000134 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700135 Hostname string `json:"hostname"` // deprecated
136 WorkingDir string `json:"working_dir"` // deprecated
137 OS string `json:"os"` // deprecated
138 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +0000139 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700140 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
141 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
142 SessionID string `json:"session_id"`
143 SSHAvailable bool `json:"ssh_available"`
144 SSHError string `json:"ssh_error,omitempty"`
145 InContainer bool `json:"in_container"`
146 FirstMessageIndex int `json:"first_message_index"`
147 AgentState string `json:"agent_state,omitempty"`
148 OutsideHostname string `json:"outside_hostname,omitempty"`
149 InsideHostname string `json:"inside_hostname,omitempty"`
150 OutsideOS string `json:"outside_os,omitempty"`
151 InsideOS string `json:"inside_os,omitempty"`
152 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
153 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700154 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
155 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
156 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
157 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700158 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
159 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000160 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
banksean5ab8fb82025-07-09 12:34:55 -0700161 TokenContextWindow int `json:"token_context_window,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000162}
163
164// Port represents an open TCP port
165type Port struct {
166 Proto string `json:"proto"` // "tcp" or "udp"
167 Port uint16 `json:"port"` // port number
168 Process string `json:"process"` // optional process name
169 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700170}
171
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700172type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700173 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
174 HostAddr string `json:"host_addr"`
175
176 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000177 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
178 SSHServerIdentity []byte `json:"ssh_server_identity"`
179 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
180 SSHHostCertificate []byte `json:"ssh_host_certificate"`
181 SSHAvailable bool `json:"ssh_available"`
182 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700183}
184
Earl Lee2e463fb2025-04-17 11:22:22 -0700185// Server serves sketch HTTP. Server implements http.Handler.
186type Server struct {
187 mux *http.ServeMux
188 agent loop.CodingAgent
189 hostname string
190 logFile *os.File
191 // Mutex to protect terminalSessions
192 ptyMutex sync.Mutex
193 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000194 sshAvailable bool
195 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700196}
197
198func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000199 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
200 if port := s.ParsePortProxyHost(r.Host); port != "" {
201 s.proxyToPort(w, r, port)
202 return
203 }
204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 s.mux.ServeHTTP(w, r)
206}
207
Philip Zeyligera9710d72025-07-02 02:50:14 +0000208// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
209func (s *Server) ParsePortProxyHost(host string) string {
210 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
211 hostname := host
212 if idx := strings.LastIndex(host, ":"); idx > 0 {
213 hostname = host[:idx]
214 }
215
216 // Check if hostname matches p<port>.localhost pattern
217 if strings.HasSuffix(hostname, ".localhost") {
218 prefix := strings.TrimSuffix(hostname, ".localhost")
219 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
220 port := prefix[1:] // Remove 'p' prefix
221 // Basic validation - port should be numeric and in valid range
222 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
223 return port
224 }
225 }
226 }
227
228 return ""
229}
230
231// proxyToPort proxies the request to localhost:<port>
232func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
233 // Create a reverse proxy to localhost:<port>
234 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
235 if err != nil {
236 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
237 return
238 }
239
240 proxy := httputil.NewSingleHostReverseProxy(target)
241
242 // Customize the Director to modify the request
243 originalDirector := proxy.Director
244 proxy.Director = func(req *http.Request) {
245 originalDirector(req)
246 // Set the target host
247 req.URL.Host = target.Host
248 req.URL.Scheme = target.Scheme
249 req.Host = target.Host
250 }
251
252 // Handle proxy errors
253 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
254 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
255 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
256 }
257
258 proxy.ServeHTTP(w, r)
259}
260
Earl Lee2e463fb2025-04-17 11:22:22 -0700261// New creates a new HTTP server.
262func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
263 s := &Server{
264 mux: http.NewServeMux(),
265 agent: agent,
266 hostname: getHostname(),
267 logFile: logFile,
268 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000269 sshAvailable: false,
270 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 }
272
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000273 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000274
275 // Git tool endpoints
276 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
277 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700278 s.mux.HandleFunc("/git/cat", s.handleGitCat)
279 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000280 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000281 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000282
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
284 // Check if a specific commit hash was requested
285 commit := r.URL.Query().Get("commit")
286
287 // Get the diff, optionally for a specific commit
288 var diff string
289 var err error
290 if commit != "" {
291 // Validate the commit hash format
292 if !isValidGitSHA(commit) {
293 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
294 return
295 }
296
297 diff, err = agent.Diff(&commit)
298 } else {
299 diff, err = agent.Diff(nil)
300 }
301
302 if err != nil {
303 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
304 return
305 }
306
307 w.Header().Set("Content-Type", "text/plain")
308 w.Write([]byte(diff))
309 })
310
311 // Handler for initialization called by host sketch binary when inside docker.
312 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
313 defer func() {
314 if err := recover(); err != nil {
315 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
316
317 // Return an error response to the client
318 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
319 }
320 }()
321
322 if r.Method != "POST" {
323 http.Error(w, "POST required", http.StatusBadRequest)
324 return
325 }
326
327 body, err := io.ReadAll(r.Body)
328 r.Body.Close()
329 if err != nil {
330 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
331 return
332 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700333
334 m := &InitRequest{}
335 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
337 return
338 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700339
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000340 // Store SSH availability info
341 s.sshAvailable = m.SSHAvailable
342 s.sshError = m.SSHError
343
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700344 // Start the SSH server if the init request included ssh keys.
345 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
346 go func() {
347 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000348 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700349 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000350 // Update SSH error if server fails to start
351 s.sshAvailable = false
352 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700353 }
354 }()
355 }
356
Earl Lee2e463fb2025-04-17 11:22:22 -0700357 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700358 InDocker: true,
359 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700360 }
361 if err := agent.Init(ini); err != nil {
362 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
363 return
364 }
365 w.Header().Set("Content-Type", "application/json")
366 io.WriteString(w, "{}\n")
367 })
368
369 // Handler for /messages?start=N&end=M (start/end are optional)
370 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
371 w.Header().Set("Content-Type", "application/json")
372
373 // Extract query parameters for range
374 var start, end int
375 var err error
376
377 currentCount := agent.MessageCount()
378
379 startParam := r.URL.Query().Get("start")
380 if startParam != "" {
381 start, err = strconv.Atoi(startParam)
382 if err != nil {
383 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
384 return
385 }
386 }
387
388 endParam := r.URL.Query().Get("end")
389 if endParam != "" {
390 end, err = strconv.Atoi(endParam)
391 if err != nil {
392 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
393 return
394 }
395 } else {
396 end = currentCount
397 }
398
399 if start < 0 || start > end || end > currentCount {
400 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
401 return
402 }
403
404 start = max(0, start)
405 end = min(agent.MessageCount(), end)
406 messages := agent.Messages(start, end)
407
408 // Create a JSON encoder with indentation for pretty-printing
409 encoder := json.NewEncoder(w)
410 encoder.SetIndent("", " ") // Two spaces for each indentation level
411
412 err = encoder.Encode(messages)
413 if err != nil {
414 http.Error(w, err.Error(), http.StatusInternalServerError)
415 }
416 })
417
418 // Handler for /logs - displays the contents of the log file
419 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
420 if s.logFile == nil {
421 http.Error(w, "log file not set", http.StatusNotFound)
422 return
423 }
424 logContents, err := os.ReadFile(s.logFile.Name())
425 if err != nil {
426 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
427 return
428 }
429 w.Header().Set("Content-Type", "text/html; charset=utf-8")
430 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
431 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
432 fmt.Fprintf(w, "</body>\n</html>")
433 })
434
435 // Handler for /download - downloads both messages and status as a JSON file
436 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
437 // Set headers for file download
438 w.Header().Set("Content-Type", "application/octet-stream")
439
440 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
441 timestamp := time.Now().Format("20060102-150405")
442 filename := fmt.Sprintf("sketch-%s.json", timestamp)
443
444 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
445
446 // Get all messages
447 messageCount := agent.MessageCount()
448 messages := agent.Messages(0, messageCount)
449
450 // Get status information (usage and other metadata)
451 totalUsage := agent.TotalUsage()
452 hostname := getHostname()
453 workingDir := getWorkingDir()
454
455 // Create a combined structure with all information
456 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700457 Messages []loop.AgentMessage `json:"messages"`
458 MessageCount int `json:"message_count"`
459 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
460 Hostname string `json:"hostname"`
461 WorkingDir string `json:"working_dir"`
462 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700463 }{
464 Messages: messages,
465 MessageCount: messageCount,
466 TotalUsage: totalUsage,
467 Hostname: hostname,
468 WorkingDir: workingDir,
469 DownloadTime: time.Now().Format(time.RFC3339),
470 }
471
472 // Marshal the JSON with indentation for better readability
473 jsonData, err := json.MarshalIndent(downloadData, "", " ")
474 if err != nil {
475 http.Error(w, err.Error(), http.StatusInternalServerError)
476 return
477 }
478 w.Write(jsonData)
479 })
480
481 // The latter doesn't return until the number of messages has changed (from seen
482 // or from when this was called.)
483 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
484 pollParam := r.URL.Query().Get("poll")
485 seenParam := r.URL.Query().Get("seen")
486
487 // Get the client's current message count (if provided)
488 clientMessageCount := -1
489 var err error
490 if seenParam != "" {
491 clientMessageCount, err = strconv.Atoi(seenParam)
492 if err != nil {
493 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
494 return
495 }
496 }
497
498 serverMessageCount := agent.MessageCount()
499
500 // Let lazy clients not have to specify this.
501 if clientMessageCount == -1 {
502 clientMessageCount = serverMessageCount
503 }
504
505 if pollParam == "true" {
506 ch := make(chan string)
507 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700508 it := agent.NewIterator(r.Context(), clientMessageCount)
509 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700510 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700511 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700512 }()
513 select {
514 case <-r.Context().Done():
515 slog.DebugContext(r.Context(), "abandoned poll request")
516 return
517 case <-time.After(90 * time.Second):
518 // Let the user call /state again to get the latest to limit how long our long polls hang out.
519 slog.DebugContext(r.Context(), "longish poll request")
520 break
521 case <-ch:
522 break
523 }
524 }
525
Earl Lee2e463fb2025-04-17 11:22:22 -0700526 w.Header().Set("Content-Type", "application/json")
527
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000528 // Use the shared getState function
529 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700530
531 // Create a JSON encoder with indentation for pretty-printing
532 encoder := json.NewEncoder(w)
533 encoder.SetIndent("", " ") // Two spaces for each indentation level
534
535 err = encoder.Encode(state)
536 if err != nil {
537 http.Error(w, err.Error(), http.StatusInternalServerError)
538 }
539 })
540
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700541 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700542
543 // Terminal WebSocket handler
544 // Terminal endpoints - predefined terminals 1-9
545 // TODO: The UI doesn't actually know how to use terminals 2-9!
546 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
547 if r.Method != http.MethodGet {
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
557 sessionID := pathParts[3]
558 // Validate that the terminal ID is between 1-9
559 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
560 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
561 return
562 }
563
564 s.handleTerminalEvents(w, r, sessionID)
565 })
566
567 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
568 if r.Method != http.MethodPost {
569 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
570 return
571 }
572 pathParts := strings.Split(r.URL.Path, "/")
573 if len(pathParts) < 4 {
574 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
575 return
576 }
577 sessionID := pathParts[3]
578 s.handleTerminalInput(w, r, sessionID)
579 })
580
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700581 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700582 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700583 webuiFS := embedded.WebUIFS()
584 appShell := "sketch-app-shell.html"
585 if r.URL.Query().Has("m") {
586 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700587 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700588 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700589 })
590
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700591 // Handler for /commit-description - returns the description of a git commit
592 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
593 if r.Method != http.MethodGet {
594 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
595 return
596 }
597
598 // Get the revision parameter
599 revision := r.URL.Query().Get("revision")
600 if revision == "" {
601 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
602 return
603 }
604
605 // Run git command to get commit description
606 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
607 // Use the working directory from the agent
608 cmd.Dir = s.agent.WorkingDir()
609
610 output, err := cmd.CombinedOutput()
611 if err != nil {
612 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
613 return
614 }
615
616 // Prepare the response
617 resp := map[string]string{
618 "description": strings.TrimSpace(string(output)),
619 }
620
621 w.Header().Set("Content-Type", "application/json")
622 if err := json.NewEncoder(w).Encode(resp); err != nil {
623 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
624 }
625 })
626
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000627 // Handler for /screenshot/{id} - serves screenshot images
628 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
629 if r.Method != http.MethodGet {
630 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
631 return
632 }
633
634 // Extract the screenshot ID from the path
635 pathParts := strings.Split(r.URL.Path, "/")
636 if len(pathParts) < 3 {
637 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
638 return
639 }
640
641 screenshotID := pathParts[2]
642
643 // Validate the ID format (prevent directory traversal)
644 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
645 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
646 return
647 }
648
649 // Get the screenshot file path
650 filePath := browse.GetScreenshotPath(screenshotID)
651
652 // Check if the file exists
653 if _, err := os.Stat(filePath); os.IsNotExist(err) {
654 http.Error(w, "Screenshot not found", http.StatusNotFound)
655 return
656 }
657
658 // Serve the file
659 w.Header().Set("Content-Type", "image/png")
660 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
661 http.ServeFile(w, r, filePath)
662 })
663
Earl Lee2e463fb2025-04-17 11:22:22 -0700664 // Handler for POST /chat
665 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
666 if r.Method != http.MethodPost {
667 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
668 return
669 }
670
671 // Parse the request body
672 var requestBody struct {
673 Message string `json:"message"`
674 }
675
676 decoder := json.NewDecoder(r.Body)
677 if err := decoder.Decode(&requestBody); err != nil {
678 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
679 return
680 }
681 defer r.Body.Close()
682
683 if requestBody.Message == "" {
684 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
685 return
686 }
687
688 agent.UserMessage(r.Context(), requestBody.Message)
689
690 w.WriteHeader(http.StatusOK)
691 })
692
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000693 // Handler for POST /upload - uploads a file to /tmp
694 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
695 if r.Method != http.MethodPost {
696 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
697 return
698 }
699
700 // Limit to 10MB file size
701 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
702
703 // Parse the multipart form
704 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
705 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
706 return
707 }
708
709 // Get the file from the multipart form
710 file, handler, err := r.FormFile("file")
711 if err != nil {
712 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
713 return
714 }
715 defer file.Close()
716
717 // Generate a unique ID (8 random bytes converted to 16 hex chars)
718 randBytes := make([]byte, 8)
719 if _, err := rand.Read(randBytes); err != nil {
720 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
721 return
722 }
723
724 // Get file extension from the original filename
725 ext := filepath.Ext(handler.Filename)
726
727 // Create a unique filename in the /tmp directory
728 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
729
730 // Create the destination file
731 destFile, err := os.Create(filename)
732 if err != nil {
733 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
734 return
735 }
736 defer destFile.Close()
737
738 // Copy the file contents to the destination file
739 if _, err := io.Copy(destFile, file); err != nil {
740 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
741 return
742 }
743
744 // Return the path to the saved file
745 w.Header().Set("Content-Type", "application/json")
746 json.NewEncoder(w).Encode(map[string]string{"path": filename})
747 })
748
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700749 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
750 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
751
752 // Handler for /git/push - handles git push operations
753 s.mux.HandleFunc("/git/push", s.handleGitPush)
754
Earl Lee2e463fb2025-04-17 11:22:22 -0700755 // Handler for /cancel - cancels the current inner loop in progress
756 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
757 if r.Method != http.MethodPost {
758 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
759 return
760 }
761
762 // Parse the request body (optional)
763 var requestBody struct {
764 Reason string `json:"reason"`
765 ToolCallID string `json:"tool_call_id"`
766 }
767
768 decoder := json.NewDecoder(r.Body)
769 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
770 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
771 return
772 }
773 defer r.Body.Close()
774
775 cancelReason := "user requested cancellation"
776 if requestBody.Reason != "" {
777 cancelReason = requestBody.Reason
778 }
779
780 if requestBody.ToolCallID != "" {
781 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
782 if err != nil {
783 http.Error(w, err.Error(), http.StatusBadRequest)
784 return
785 }
786 // Return a success response
787 w.Header().Set("Content-Type", "application/json")
788 json.NewEncoder(w).Encode(map[string]string{
789 "status": "cancelled",
790 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700791 "reason": cancelReason,
792 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 return
794 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000795 // Call the CancelTurn method
796 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 // Return a success response
798 w.Header().Set("Content-Type", "application/json")
799 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
800 })
801
Pokey Rule397871d2025-05-19 15:02:45 +0100802 // Handler for /end - shuts down the inner sketch process
803 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
804 if r.Method != http.MethodPost {
805 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
806 return
807 }
808
809 // Parse the request body (optional)
810 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700811 Reason string `json:"reason"`
812 Happy *bool `json:"happy,omitempty"`
813 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100814 }
815
816 decoder := json.NewDecoder(r.Body)
817 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
818 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
819 return
820 }
821 defer r.Body.Close()
822
823 endReason := "user requested end of session"
824 if requestBody.Reason != "" {
825 endReason = requestBody.Reason
826 }
827
828 // Send success response before exiting
829 w.Header().Set("Content-Type", "application/json")
830 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
831 if f, ok := w.(http.Flusher); ok {
832 f.Flush()
833 }
834
835 // Log that we're shutting down
836 slog.Info("Ending session", "reason", endReason)
837
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000838 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100839 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000840 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100841 os.Exit(0)
842 }()
843 })
844
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700845 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700846 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
847 debugMux.ServeHTTP(w, r)
848 })
849
850 return s, nil
851}
852
853// Utility functions
854func getHostname() string {
855 hostname, err := os.Hostname()
856 if err != nil {
857 return "unknown"
858 }
859 return hostname
860}
861
862func getWorkingDir() string {
863 wd, err := os.Getwd()
864 if err != nil {
865 return "unknown"
866 }
867 return wd
868}
869
870// createTerminalSession creates a new terminal session with the given ID
871func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
872 // Start a new shell process
873 shellPath := getShellPath()
874 cmd := exec.Command(shellPath)
875
876 // Get working directory from the agent if possible
877 workDir := getWorkingDir()
878 cmd.Dir = workDir
879
880 // Set up environment
881 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
882
883 // Start the command with a pty
884 ptmx, err := pty.Start(cmd)
885 if err != nil {
886 slog.Error("Failed to start pty", "error", err)
887 return nil, err
888 }
889
890 // Create the terminal session
891 session := &terminalSession{
892 pty: ptmx,
893 eventsClients: make(map[chan []byte]bool),
894 cmd: cmd,
895 }
896
897 // Start goroutine to read from pty and broadcast to all connected SSE clients
898 go s.readFromPtyAndBroadcast(sessionID, session)
899
900 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000901}
902
903// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700904func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
905 // Check if the session exists, if not, create it
906 s.ptyMutex.Lock()
907 session, exists := s.terminalSessions[sessionID]
908
909 if !exists {
910 // Create a new terminal session
911 var err error
912 session, err = s.createTerminalSession(sessionID)
913 if err != nil {
914 s.ptyMutex.Unlock()
915 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
916 return
917 }
918
919 // Store the new session
920 s.terminalSessions[sessionID] = session
921 }
922 s.ptyMutex.Unlock()
923
924 // Set headers for SSE
925 w.Header().Set("Content-Type", "text/event-stream")
926 w.Header().Set("Cache-Control", "no-cache")
927 w.Header().Set("Connection", "keep-alive")
928 w.Header().Set("Access-Control-Allow-Origin", "*")
929
930 // Create a channel for this client
931 events := make(chan []byte, 4096) // Buffer to prevent blocking
932
933 // Register this client's channel
934 session.eventsClientsMutex.Lock()
935 clientID := session.lastEventClientID + 1
936 session.lastEventClientID = clientID
937 session.eventsClients[events] = true
938 session.eventsClientsMutex.Unlock()
939
940 // When the client disconnects, remove their channel
941 defer func() {
942 session.eventsClientsMutex.Lock()
943 delete(session.eventsClients, events)
944 close(events)
945 session.eventsClientsMutex.Unlock()
946 }()
947
948 // Flush to send headers to client immediately
949 if f, ok := w.(http.Flusher); ok {
950 f.Flush()
951 }
952
953 // Send events to the client as they arrive
954 for {
955 select {
956 case <-r.Context().Done():
957 return
958 case data := <-events:
959 // Format as SSE with base64 encoding
960 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
961
962 // Flush the data immediately
963 if f, ok := w.(http.Flusher); ok {
964 f.Flush()
965 }
966 }
967 }
968}
969
970// handleTerminalInput processes input to the terminal
971func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
972 // Check if the session exists
973 s.ptyMutex.Lock()
974 session, exists := s.terminalSessions[sessionID]
975 s.ptyMutex.Unlock()
976
977 if !exists {
978 http.Error(w, "Terminal session not found", http.StatusNotFound)
979 return
980 }
981
982 // Read the request body (terminal input or resize command)
983 body, err := io.ReadAll(r.Body)
984 if err != nil {
985 http.Error(w, "Failed to read request body", http.StatusBadRequest)
986 return
987 }
988
989 // Check if it's a resize message
990 if len(body) > 0 && body[0] == '{' {
991 var msg TerminalMessage
992 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
993 if msg.Cols > 0 && msg.Rows > 0 {
994 pty.Setsize(session.pty, &pty.Winsize{
995 Cols: msg.Cols,
996 Rows: msg.Rows,
997 })
998
999 // Respond with success
1000 w.WriteHeader(http.StatusOK)
1001 return
1002 }
1003 }
1004 }
1005
1006 // Regular terminal input
1007 _, err = session.pty.Write(body)
1008 if err != nil {
1009 slog.Error("Failed to write to pty", "error", err)
1010 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
1011 return
1012 }
1013
1014 // Respond with success
1015 w.WriteHeader(http.StatusOK)
1016}
1017
1018// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1019func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1020 buf := make([]byte, 4096)
1021 defer func() {
1022 // Clean up when done
1023 s.ptyMutex.Lock()
1024 delete(s.terminalSessions, sessionID)
1025 s.ptyMutex.Unlock()
1026
1027 // Close the PTY
1028 session.pty.Close()
1029
1030 // Ensure process is terminated
1031 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001032 session.cmd.Process.Kill()
1033 }
David Crawshawb8431462025-07-09 13:10:32 +10001034 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001035
1036 // Close all client channels
1037 session.eventsClientsMutex.Lock()
1038 for ch := range session.eventsClients {
1039 delete(session.eventsClients, ch)
1040 close(ch)
1041 }
1042 session.eventsClientsMutex.Unlock()
1043 }()
1044
1045 for {
1046 n, err := session.pty.Read(buf)
1047 if err != nil {
1048 if err != io.EOF {
1049 slog.Error("Failed to read from pty", "error", err)
1050 }
1051 break
1052 }
1053
1054 // Make a copy of the data for each client
1055 data := make([]byte, n)
1056 copy(data, buf[:n])
1057
1058 // Broadcast to all connected clients
1059 session.eventsClientsMutex.Lock()
1060 for ch := range session.eventsClients {
1061 // Try to send, but don't block if channel is full
1062 select {
1063 case ch <- data:
1064 default:
1065 // Channel is full, drop the message for this client
1066 }
1067 }
1068 session.eventsClientsMutex.Unlock()
1069 }
1070}
1071
1072// getShellPath returns the path to the shell to use
1073func getShellPath() string {
1074 // Try to use the user's preferred shell
1075 shell := os.Getenv("SHELL")
1076 if shell != "" {
1077 return shell
1078 }
1079
1080 // Default to bash on Unix-like systems
1081 if _, err := os.Stat("/bin/bash"); err == nil {
1082 return "/bin/bash"
1083 }
1084
1085 // Fall back to sh
1086 return "/bin/sh"
1087}
1088
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001089func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001090 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001091 build := "unknown build"
1092 bi, ok := debug.ReadBuildInfo()
1093 if ok {
1094 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1095 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001096 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1097 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001098 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 fmt.Fprintf(w, `<!doctype html>
1100 <html><head><title>sketch debug</title></head><body>
1101 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001102 pid %d<br>
1103 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001105 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1106 <li><a href="pprof/profile">pprof/profile</a></li>
1107 <li><a href="pprof/symbol">pprof/symbol</a></li>
1108 <li><a href="pprof/trace">pprof/trace</a></li>
1109 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001110 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 </ul>
1112 </body>
1113 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001114 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 })
1116 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1117 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1118 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1119 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1120 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001121
1122 // Add conversation history debug handler
1123 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1124 w.Header().Set("Content-Type", "application/json")
1125
1126 // Use type assertion to access the GetConvo method
1127 type ConvoProvider interface {
1128 GetConvo() loop.ConvoInterface
1129 }
1130
1131 if convoProvider, ok := agent.(ConvoProvider); ok {
1132 // Call the DebugJSON method to get the conversation history
1133 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1134 if err != nil {
1135 http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
1136 return
1137 }
1138
1139 // Write the JSON response
1140 w.Write(historyJSON)
1141 } else {
1142 http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
1143 }
1144 })
1145
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 return mux
1147}
1148
1149// isValidGitSHA validates if a string looks like a valid git SHA hash.
1150// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1151func isValidGitSHA(sha string) bool {
1152 // Git SHA must be a hexadecimal string with at least 4 characters
1153 if len(sha) < 4 || len(sha) > 40 {
1154 return false
1155 }
1156
1157 // Check if the string only contains hexadecimal characters
1158 for _, char := range sha {
1159 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1160 return false
1161 }
1162 }
1163
1164 return true
1165}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001166
1167// /stream?from=N endpoint for Server-Sent Events
1168func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1169 w.Header().Set("Content-Type", "text/event-stream")
1170 w.Header().Set("Cache-Control", "no-cache")
1171 w.Header().Set("Connection", "keep-alive")
1172 w.Header().Set("Access-Control-Allow-Origin", "*")
1173
1174 // Extract the 'from' parameter
1175 fromParam := r.URL.Query().Get("from")
1176 var fromIndex int
1177 var err error
1178 if fromParam != "" {
1179 fromIndex, err = strconv.Atoi(fromParam)
1180 if err != nil {
1181 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1182 return
1183 }
1184 }
1185
1186 // Ensure 'from' is valid
1187 currentCount := s.agent.MessageCount()
1188 if fromIndex < 0 {
1189 fromIndex = 0
1190 } else if fromIndex > currentCount {
1191 fromIndex = currentCount
1192 }
1193
1194 // Send the current state immediately
1195 state := s.getState()
1196
1197 // Create JSON encoder
1198 encoder := json.NewEncoder(w)
1199
1200 // Send state as an event
1201 fmt.Fprintf(w, "event: state\n")
1202 fmt.Fprintf(w, "data: ")
1203 encoder.Encode(state)
1204 fmt.Fprintf(w, "\n\n")
1205
1206 if f, ok := w.(http.Flusher); ok {
1207 f.Flush()
1208 }
1209
1210 // Create a context for the SSE stream
1211 ctx := r.Context()
1212
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001213 // Setup heartbeat timer
1214 heartbeatTicker := time.NewTicker(45 * time.Second)
1215 defer heartbeatTicker.Stop()
1216
1217 // Create a channel for messages
1218 messageChan := make(chan *loop.AgentMessage, 10)
1219
Philip Zeyligereab12de2025-05-14 02:35:53 +00001220 // Create a channel for state transitions
1221 stateChan := make(chan *loop.StateTransition, 10)
1222
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001223 // Start a goroutine to read messages without blocking the heartbeat
1224 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001225 // Create an iterator to receive new messages as they arrive
1226 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1227 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001228 defer close(messageChan)
1229 for {
1230 // This can block, but it's in its own goroutine
1231 newMessage := iterator.Next()
1232 if newMessage == nil {
1233 // No message available (likely due to context cancellation)
1234 slog.InfoContext(ctx, "No more messages available, ending message stream")
1235 return
1236 }
1237
1238 select {
1239 case messageChan <- newMessage:
1240 // Message sent to channel
1241 case <-ctx.Done():
1242 // Context cancelled
1243 return
1244 }
1245 }
1246 }()
1247
Philip Zeyligereab12de2025-05-14 02:35:53 +00001248 // Start a goroutine to read state transitions
1249 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001250 // Create an iterator to receive state transitions
1251 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1252 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001253 defer close(stateChan)
1254 for {
1255 // This can block, but it's in its own goroutine
1256 newTransition := stateIterator.Next()
1257 if newTransition == nil {
1258 // No transition available (likely due to context cancellation)
1259 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1260 return
1261 }
1262
1263 select {
1264 case stateChan <- newTransition:
1265 // Transition sent to channel
1266 case <-ctx.Done():
1267 // Context cancelled
1268 return
1269 }
1270 }
1271 }()
1272
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001273 // Stay connected and stream real-time updates
1274 for {
1275 select {
1276 case <-heartbeatTicker.C:
1277 // Send heartbeat event
1278 fmt.Fprintf(w, "event: heartbeat\n")
1279 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1280
1281 // Flush to send the heartbeat immediately
1282 if f, ok := w.(http.Flusher); ok {
1283 f.Flush()
1284 }
1285
1286 case <-ctx.Done():
1287 // Client disconnected
1288 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1289 return
1290
Philip Zeyligereab12de2025-05-14 02:35:53 +00001291 case _, ok := <-stateChan:
1292 if !ok {
1293 // Channel closed
1294 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1295 return
1296 }
1297
1298 // Get updated state
1299 state = s.getState()
1300
1301 // Send updated state after the state transition
1302 fmt.Fprintf(w, "event: state\n")
1303 fmt.Fprintf(w, "data: ")
1304 encoder.Encode(state)
1305 fmt.Fprintf(w, "\n\n")
1306
1307 // Flush to send the state immediately
1308 if f, ok := w.(http.Flusher); ok {
1309 f.Flush()
1310 }
1311
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001312 case newMessage, ok := <-messageChan:
1313 if !ok {
1314 // Channel closed
1315 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1316 return
1317 }
1318
1319 // Send the new message as an event
1320 fmt.Fprintf(w, "event: message\n")
1321 fmt.Fprintf(w, "data: ")
1322 encoder.Encode(newMessage)
1323 fmt.Fprintf(w, "\n\n")
1324
1325 // Get updated state
1326 state = s.getState()
1327
1328 // Send updated state after the message
1329 fmt.Fprintf(w, "event: state\n")
1330 fmt.Fprintf(w, "data: ")
1331 encoder.Encode(state)
1332 fmt.Fprintf(w, "\n\n")
1333
1334 // Flush to send the message and state immediately
1335 if f, ok := w.(http.Flusher); ok {
1336 f.Flush()
1337 }
1338 }
1339 }
1340}
1341
1342// Helper function to get the current state
1343func (s *Server) getState() State {
1344 serverMessageCount := s.agent.MessageCount()
1345 totalUsage := s.agent.TotalUsage()
1346
Philip Zeyliger64f60462025-06-16 13:57:10 -07001347 // Get diff stats
1348 diffAdded, diffRemoved := s.agent.DiffStats()
1349
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001350 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001351 StateVersion: 2,
1352 MessageCount: serverMessageCount,
1353 TotalUsage: &totalUsage,
1354 Hostname: s.hostname,
1355 WorkingDir: getWorkingDir(),
1356 // TODO: Rename this field to sketch-base?
1357 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001358 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001359 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001360 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001361 OS: s.agent.OS(),
1362 OutsideHostname: s.agent.OutsideHostname(),
1363 InsideHostname: s.hostname,
1364 OutsideOS: s.agent.OutsideOS(),
1365 InsideOS: s.agent.OS(),
1366 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1367 InsideWorkingDir: getWorkingDir(),
1368 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001369 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001370 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1371 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1372 SessionID: s.agent.SessionID(),
1373 SSHAvailable: s.sshAvailable,
1374 SSHError: s.sshError,
1375 InContainer: s.agent.IsInContainer(),
1376 FirstMessageIndex: s.agent.FirstMessageIndex(),
1377 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001378 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001379 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001380 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001381 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001382 DiffLinesAdded: diffAdded,
1383 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001384 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001385 TokenContextWindow: s.agent.TokenContextWindow(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001386 }
1387}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001388
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001389// getOpenPorts retrieves the current open ports from the agent
1390func (s *Server) getOpenPorts() []Port {
1391 ports := s.agent.GetPorts()
1392 if ports == nil {
1393 return nil
1394 }
1395
1396 result := make([]Port, len(ports))
1397 for i, port := range ports {
1398 result[i] = Port{
1399 Proto: port.Proto,
1400 Port: port.Port,
1401 Process: port.Process,
1402 Pid: port.Pid,
1403 }
1404 }
1405 return result
1406}
1407
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001408func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1409 if r.Method != "GET" {
1410 w.WriteHeader(http.StatusMethodNotAllowed)
1411 return
1412 }
1413
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001414 // Get the git repository root directory from agent
1415 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001416
1417 // Parse query parameters
1418 query := r.URL.Query()
1419 commit := query.Get("commit")
1420 from := query.Get("from")
1421 to := query.Get("to")
1422
1423 // If commit is specified, use commit^ and commit as from and to
1424 if commit != "" {
1425 from = commit + "^"
1426 to = commit
1427 }
1428
1429 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001430 if from == "" {
1431 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001432 return
1433 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001434 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001435
1436 // Call the git_tools function
1437 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1438 if err != nil {
1439 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1440 return
1441 }
1442
1443 // Return the result as JSON
1444 w.Header().Set("Content-Type", "application/json")
1445 if err := json.NewEncoder(w).Encode(diff); err != nil {
1446 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1447 return
1448 }
1449}
1450
1451func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1452 if r.Method != "GET" {
1453 w.WriteHeader(http.StatusMethodNotAllowed)
1454 return
1455 }
1456
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001457 // Get the git repository root directory from agent
1458 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001459
1460 // Parse query parameters
1461 hash := r.URL.Query().Get("hash")
1462 if hash == "" {
1463 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1464 return
1465 }
1466
1467 // Call the git_tools function
1468 show, err := git_tools.GitShow(repoDir, hash)
1469 if err != nil {
1470 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1471 return
1472 }
1473
1474 // Create a JSON response
1475 response := map[string]string{
1476 "hash": hash,
1477 "output": show,
1478 }
1479
1480 // Return the result as JSON
1481 w.Header().Set("Content-Type", "application/json")
1482 if err := json.NewEncoder(w).Encode(response); err != nil {
1483 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1484 return
1485 }
1486}
1487
1488func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1489 if r.Method != "GET" {
1490 w.WriteHeader(http.StatusMethodNotAllowed)
1491 return
1492 }
1493
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001494 // Get the git repository root directory and initial commit from agent
1495 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001496 initialCommit := s.agent.SketchGitBaseRef()
1497
1498 // Call the git_tools function
1499 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1500 if err != nil {
1501 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1502 return
1503 }
1504
1505 // Return the result as JSON
1506 w.Header().Set("Content-Type", "application/json")
1507 if err := json.NewEncoder(w).Encode(log); err != nil {
1508 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1509 return
1510 }
1511}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001512
1513func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1514 if r.Method != "GET" {
1515 w.WriteHeader(http.StatusMethodNotAllowed)
1516 return
1517 }
1518
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001519 // Get the git repository root directory from agent
1520 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001521
1522 // Parse query parameters
1523 query := r.URL.Query()
1524 path := query.Get("path")
1525
1526 // Check if path is provided
1527 if path == "" {
1528 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1529 return
1530 }
1531
1532 // Get file content using GitCat
1533 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001534 switch {
1535 case err == nil:
1536 // continued below
1537 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001538 w.WriteHeader(http.StatusNoContent)
1539 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001540 default:
1541 http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001542 return
1543 }
1544
1545 // Return the content as JSON for consistency with other endpoints
1546 w.Header().Set("Content-Type", "application/json")
1547 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1548 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1549 return
1550 }
1551}
1552
1553func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1554 if r.Method != "POST" {
1555 w.WriteHeader(http.StatusMethodNotAllowed)
1556 return
1557 }
1558
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001559 // Get the git repository root directory from agent
1560 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001561
1562 // Parse request body
1563 var requestBody struct {
1564 Path string `json:"path"`
1565 Content string `json:"content"`
1566 }
1567
1568 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1569 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1570 return
1571 }
1572 defer r.Body.Close()
1573
1574 // Check if path is provided
1575 if requestBody.Path == "" {
1576 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1577 return
1578 }
1579
1580 // Save file content using GitSaveFile
1581 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1582 if err != nil {
1583 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1584 return
1585 }
1586
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001587 // Auto-commit the changes
1588 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1589 if err != nil {
1590 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1591 return
1592 }
1593
1594 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001595 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1596 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1597 return
1598 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001599
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001600 // Return simple success response
1601 w.WriteHeader(http.StatusOK)
1602 w.Write([]byte("ok"))
1603}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001604
1605func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1606 if r.Method != "GET" {
1607 w.WriteHeader(http.StatusMethodNotAllowed)
1608 return
1609 }
1610
1611 repoDir := s.agent.RepoRoot()
1612 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1613 if err != nil {
1614 http.Error(w, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
1615 return
1616 }
1617
1618 w.Header().Set("Content-Type", "application/json")
1619 response := map[string][]string{
1620 "untracked_files": untrackedFiles,
1621 }
1622 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1623}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001624
1625// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1626func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1627 if r.Method != "GET" {
1628 w.WriteHeader(http.StatusMethodNotAllowed)
1629 return
1630 }
1631
1632 repoDir := s.agent.RepoRoot()
1633
1634 // Get the current HEAD commit hash and subject in one command
1635 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1636 cmd.Dir = repoDir
1637 output, err := cmd.Output()
1638 if err != nil {
1639 http.Error(w, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
1640 return
1641 }
1642
1643 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1644 if len(parts) != 2 {
1645 http.Error(w, "Unexpected git log output format", http.StatusInternalServerError)
1646 return
1647 }
1648 hash := parts[0]
1649 subject := parts[1]
1650
1651 // Get list of remote names
1652 cmd = exec.Command("git", "remote")
1653 cmd.Dir = repoDir
1654 output, err = cmd.Output()
1655 if err != nil {
1656 http.Error(w, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
1657 return
1658 }
1659
1660 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1661
1662 remotes := make([]Remote, 0, len(remoteNames))
1663
1664 // Get URL and display name for each remote
1665 for _, remoteName := range remoteNames {
1666 cmd = exec.Command("git", "remote", "get-url", remoteName)
1667 cmd.Dir = repoDir
1668 urlOutput, err := cmd.Output()
1669 if err != nil {
1670 // Skip this remote if we can't get its URL
1671 continue
1672 }
1673 url := strings.TrimSpace(string(urlOutput))
1674
1675 // Set display name based on passthrough-upstream and remote name
1676 var displayName string
1677 var isGitHub bool
1678 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1679 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1680 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1681 isGitHub = false
1682 } else if remoteName == "origin" || remoteName == "upstream" {
1683 // Use git_origin value, simplified for GitHub URLs
1684 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1685 } else {
1686 // For other remotes, use the remote URL directly
1687 displayName, isGitHub = simplifyGitHubURL(url)
1688 }
1689
1690 remotes = append(remotes, Remote{
1691 Name: remoteName,
1692 URL: url,
1693 DisplayName: displayName,
1694 IsGitHub: isGitHub,
1695 })
1696 }
1697
1698 w.Header().Set("Content-Type", "application/json")
1699 response := GitPushInfoResponse{
1700 Hash: hash,
1701 Subject: subject,
1702 Remotes: remotes,
1703 }
1704 _ = json.NewEncoder(w).Encode(response)
1705}
1706
1707// handleGitPush handles git push operations
1708func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1709 if r.Method != "POST" {
1710 w.WriteHeader(http.StatusMethodNotAllowed)
1711 return
1712 }
1713
1714 // Parse request body
1715 var requestBody GitPushRequest
1716
1717 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1718 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1719 return
1720 }
1721 defer r.Body.Close()
1722
1723 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
1724 http.Error(w, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
1725 return
1726 }
1727
1728 repoDir := s.agent.RepoRoot()
1729
1730 // Build the git push command
1731 args := []string{"push"}
1732 if requestBody.DryRun {
1733 args = append(args, "--dry-run")
1734 }
1735 if requestBody.Force {
1736 args = append(args, "--force")
1737 }
1738
1739 // Determine the target refspec
1740 var targetRef string
1741 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1742 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1743 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1744 } else {
1745 // Normal case: push to refs/heads/<branch>
1746 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1747 }
1748
1749 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1750
1751 // Log the git push command being executed
1752 slog.InfoContext(r.Context(), "executing git push command",
1753 "command", "git",
1754 "args", args,
1755 "remote", requestBody.Remote,
1756 "branch", requestBody.Branch,
1757 "commit", requestBody.Commit,
1758 "target_ref", targetRef,
1759 "dry_run", requestBody.DryRun,
1760 "force", requestBody.Force,
1761 "repo_dir", repoDir)
1762
1763 cmd := exec.Command("git", args...)
1764 cmd.Dir = repoDir
1765 // Ideally we want to pass an extra HTTP header so that the
1766 // server can know that this was likely a user initiated action
1767 // and not an agent-initiated action. However, git push weirdly
1768 // doesn't take a "-c" option, and the only handy env variable that
1769 // because a header is the user agent, so we abuse it...
1770 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1771 output, err := cmd.CombinedOutput()
1772
1773 // Log the result of the git push command
1774 if err != nil {
1775 slog.WarnContext(r.Context(), "git push command failed",
1776 "error", err,
1777 "output", string(output),
1778 "args", args)
1779 } else {
1780 slog.InfoContext(r.Context(), "git push command completed successfully",
1781 "output", string(output),
1782 "args", args)
1783 }
1784
1785 // Prepare response
1786 response := GitPushResponse{
1787 Success: err == nil,
1788 Output: string(output),
1789 DryRun: requestBody.DryRun,
1790 }
1791
1792 if err != nil {
1793 response.Error = err.Error()
1794 }
1795
1796 w.Header().Set("Content-Type", "application/json")
1797 _ = json.NewEncoder(w).Encode(response)
1798}