blob: dcb315f50e04133a1ebf89c0ff77abf6db46ce34 [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"`
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000162 Model string `json:"model,omitempty"` // Name of the model being used
bankseanc67d7bc2025-07-23 10:59:02 -0700163 SessionEnded bool `json:"session_ended,omitempty"`
164 CanSendMessages bool `json:"can_send_messages,omitempty"`
165 EndedAt time.Time `json:"ended_at,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000166}
167
168// Port represents an open TCP port
169type Port struct {
170 Proto string `json:"proto"` // "tcp" or "udp"
171 Port uint16 `json:"port"` // port number
172 Process string `json:"process"` // optional process name
173 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700174}
175
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700176type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700177 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
178 HostAddr string `json:"host_addr"`
179
180 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000181 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
182 SSHServerIdentity []byte `json:"ssh_server_identity"`
183 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
184 SSHHostCertificate []byte `json:"ssh_host_certificate"`
185 SSHAvailable bool `json:"ssh_available"`
186 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700187}
188
Earl Lee2e463fb2025-04-17 11:22:22 -0700189// Server serves sketch HTTP. Server implements http.Handler.
190type Server struct {
191 mux *http.ServeMux
192 agent loop.CodingAgent
193 hostname string
194 logFile *os.File
195 // Mutex to protect terminalSessions
196 ptyMutex sync.Mutex
197 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000198 sshAvailable bool
199 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700200}
201
202func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000203 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
204 if port := s.ParsePortProxyHost(r.Host); port != "" {
205 s.proxyToPort(w, r, port)
206 return
207 }
208
Earl Lee2e463fb2025-04-17 11:22:22 -0700209 s.mux.ServeHTTP(w, r)
210}
211
Philip Zeyligera9710d72025-07-02 02:50:14 +0000212// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
213func (s *Server) ParsePortProxyHost(host string) string {
214 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
215 hostname := host
216 if idx := strings.LastIndex(host, ":"); idx > 0 {
217 hostname = host[:idx]
218 }
219
220 // Check if hostname matches p<port>.localhost pattern
221 if strings.HasSuffix(hostname, ".localhost") {
222 prefix := strings.TrimSuffix(hostname, ".localhost")
223 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
224 port := prefix[1:] // Remove 'p' prefix
225 // Basic validation - port should be numeric and in valid range
226 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
227 return port
228 }
229 }
230 }
231
232 return ""
233}
234
235// proxyToPort proxies the request to localhost:<port>
236func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
237 // Create a reverse proxy to localhost:<port>
238 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
239 if err != nil {
240 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
241 return
242 }
243
244 proxy := httputil.NewSingleHostReverseProxy(target)
245
246 // Customize the Director to modify the request
247 originalDirector := proxy.Director
248 proxy.Director = func(req *http.Request) {
249 originalDirector(req)
250 // Set the target host
251 req.URL.Host = target.Host
252 req.URL.Scheme = target.Scheme
253 req.Host = target.Host
254 }
255
256 // Handle proxy errors
257 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
258 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
259 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
260 }
261
262 proxy.ServeHTTP(w, r)
263}
264
Earl Lee2e463fb2025-04-17 11:22:22 -0700265// New creates a new HTTP server.
266func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
267 s := &Server{
268 mux: http.NewServeMux(),
269 agent: agent,
270 hostname: getHostname(),
271 logFile: logFile,
272 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000273 sshAvailable: false,
274 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 }
276
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000277 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000278
279 // Git tool endpoints
280 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
281 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700282 s.mux.HandleFunc("/git/cat", s.handleGitCat)
283 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000284 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000285 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000286
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
288 // Check if a specific commit hash was requested
289 commit := r.URL.Query().Get("commit")
290
291 // Get the diff, optionally for a specific commit
292 var diff string
293 var err error
294 if commit != "" {
295 // Validate the commit hash format
296 if !isValidGitSHA(commit) {
297 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
298 return
299 }
300
301 diff, err = agent.Diff(&commit)
302 } else {
303 diff, err = agent.Diff(nil)
304 }
305
306 if err != nil {
307 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
308 return
309 }
310
311 w.Header().Set("Content-Type", "text/plain")
312 w.Write([]byte(diff))
313 })
314
315 // Handler for initialization called by host sketch binary when inside docker.
316 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
317 defer func() {
318 if err := recover(); err != nil {
319 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
320
321 // Return an error response to the client
322 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
323 }
324 }()
325
326 if r.Method != "POST" {
327 http.Error(w, "POST required", http.StatusBadRequest)
328 return
329 }
330
331 body, err := io.ReadAll(r.Body)
332 r.Body.Close()
333 if err != nil {
334 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
335 return
336 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700337
338 m := &InitRequest{}
339 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700340 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
341 return
342 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700343
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000344 // Store SSH availability info
345 s.sshAvailable = m.SSHAvailable
346 s.sshError = m.SSHError
347
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700348 // Start the SSH server if the init request included ssh keys.
349 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
350 go func() {
351 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000352 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700353 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000354 // Update SSH error if server fails to start
355 s.sshAvailable = false
356 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700357 }
358 }()
359 }
360
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700362 InDocker: true,
363 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700364 }
365 if err := agent.Init(ini); err != nil {
366 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
367 return
368 }
369 w.Header().Set("Content-Type", "application/json")
370 io.WriteString(w, "{}\n")
371 })
372
373 // Handler for /messages?start=N&end=M (start/end are optional)
374 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
375 w.Header().Set("Content-Type", "application/json")
376
377 // Extract query parameters for range
378 var start, end int
379 var err error
380
381 currentCount := agent.MessageCount()
382
383 startParam := r.URL.Query().Get("start")
384 if startParam != "" {
385 start, err = strconv.Atoi(startParam)
386 if err != nil {
387 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
388 return
389 }
390 }
391
392 endParam := r.URL.Query().Get("end")
393 if endParam != "" {
394 end, err = strconv.Atoi(endParam)
395 if err != nil {
396 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
397 return
398 }
399 } else {
400 end = currentCount
401 }
402
403 if start < 0 || start > end || end > currentCount {
404 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
405 return
406 }
407
408 start = max(0, start)
409 end = min(agent.MessageCount(), end)
410 messages := agent.Messages(start, end)
411
412 // Create a JSON encoder with indentation for pretty-printing
413 encoder := json.NewEncoder(w)
414 encoder.SetIndent("", " ") // Two spaces for each indentation level
415
416 err = encoder.Encode(messages)
417 if err != nil {
418 http.Error(w, err.Error(), http.StatusInternalServerError)
419 }
420 })
421
422 // Handler for /logs - displays the contents of the log file
423 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
424 if s.logFile == nil {
425 http.Error(w, "log file not set", http.StatusNotFound)
426 return
427 }
428 logContents, err := os.ReadFile(s.logFile.Name())
429 if err != nil {
430 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
431 return
432 }
433 w.Header().Set("Content-Type", "text/html; charset=utf-8")
434 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
435 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
436 fmt.Fprintf(w, "</body>\n</html>")
437 })
438
439 // Handler for /download - downloads both messages and status as a JSON file
440 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
441 // Set headers for file download
442 w.Header().Set("Content-Type", "application/octet-stream")
443
444 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
445 timestamp := time.Now().Format("20060102-150405")
446 filename := fmt.Sprintf("sketch-%s.json", timestamp)
447
448 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
449
450 // Get all messages
451 messageCount := agent.MessageCount()
452 messages := agent.Messages(0, messageCount)
453
454 // Get status information (usage and other metadata)
455 totalUsage := agent.TotalUsage()
456 hostname := getHostname()
457 workingDir := getWorkingDir()
458
459 // Create a combined structure with all information
460 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700461 Messages []loop.AgentMessage `json:"messages"`
462 MessageCount int `json:"message_count"`
463 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
464 Hostname string `json:"hostname"`
465 WorkingDir string `json:"working_dir"`
466 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700467 }{
468 Messages: messages,
469 MessageCount: messageCount,
470 TotalUsage: totalUsage,
471 Hostname: hostname,
472 WorkingDir: workingDir,
473 DownloadTime: time.Now().Format(time.RFC3339),
474 }
475
476 // Marshal the JSON with indentation for better readability
477 jsonData, err := json.MarshalIndent(downloadData, "", " ")
478 if err != nil {
479 http.Error(w, err.Error(), http.StatusInternalServerError)
480 return
481 }
482 w.Write(jsonData)
483 })
484
485 // The latter doesn't return until the number of messages has changed (from seen
486 // or from when this was called.)
487 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
488 pollParam := r.URL.Query().Get("poll")
489 seenParam := r.URL.Query().Get("seen")
490
491 // Get the client's current message count (if provided)
492 clientMessageCount := -1
493 var err error
494 if seenParam != "" {
495 clientMessageCount, err = strconv.Atoi(seenParam)
496 if err != nil {
497 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
498 return
499 }
500 }
501
502 serverMessageCount := agent.MessageCount()
503
504 // Let lazy clients not have to specify this.
505 if clientMessageCount == -1 {
506 clientMessageCount = serverMessageCount
507 }
508
509 if pollParam == "true" {
510 ch := make(chan string)
511 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700512 it := agent.NewIterator(r.Context(), clientMessageCount)
513 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700514 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700515 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700516 }()
517 select {
518 case <-r.Context().Done():
519 slog.DebugContext(r.Context(), "abandoned poll request")
520 return
521 case <-time.After(90 * time.Second):
522 // Let the user call /state again to get the latest to limit how long our long polls hang out.
523 slog.DebugContext(r.Context(), "longish poll request")
524 break
525 case <-ch:
526 break
527 }
528 }
529
Earl Lee2e463fb2025-04-17 11:22:22 -0700530 w.Header().Set("Content-Type", "application/json")
531
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000532 // Use the shared getState function
533 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700534
535 // Create a JSON encoder with indentation for pretty-printing
536 encoder := json.NewEncoder(w)
537 encoder.SetIndent("", " ") // Two spaces for each indentation level
538
539 err = encoder.Encode(state)
540 if err != nil {
541 http.Error(w, err.Error(), http.StatusInternalServerError)
542 }
543 })
544
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700545 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700546
547 // Terminal WebSocket handler
548 // Terminal endpoints - predefined terminals 1-9
549 // TODO: The UI doesn't actually know how to use terminals 2-9!
550 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
551 if r.Method != http.MethodGet {
552 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
553 return
554 }
555 pathParts := strings.Split(r.URL.Path, "/")
556 if len(pathParts) < 4 {
557 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
558 return
559 }
560
561 sessionID := pathParts[3]
562 // Validate that the terminal ID is between 1-9
563 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
564 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
565 return
566 }
567
568 s.handleTerminalEvents(w, r, sessionID)
569 })
570
571 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
572 if r.Method != http.MethodPost {
573 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
574 return
575 }
576 pathParts := strings.Split(r.URL.Path, "/")
577 if len(pathParts) < 4 {
578 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
579 return
580 }
581 sessionID := pathParts[3]
582 s.handleTerminalInput(w, r, sessionID)
583 })
584
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700585 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700586 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700587 webuiFS := embedded.WebUIFS()
588 appShell := "sketch-app-shell.html"
589 if r.URL.Query().Has("m") {
590 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700591 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700592 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700593 })
594
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700595 // Handler for /commit-description - returns the description of a git commit
596 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
597 if r.Method != http.MethodGet {
598 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
599 return
600 }
601
602 // Get the revision parameter
603 revision := r.URL.Query().Get("revision")
604 if revision == "" {
605 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
606 return
607 }
608
609 // Run git command to get commit description
610 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
611 // Use the working directory from the agent
612 cmd.Dir = s.agent.WorkingDir()
613
614 output, err := cmd.CombinedOutput()
615 if err != nil {
616 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
617 return
618 }
619
620 // Prepare the response
621 resp := map[string]string{
622 "description": strings.TrimSpace(string(output)),
623 }
624
625 w.Header().Set("Content-Type", "application/json")
626 if err := json.NewEncoder(w).Encode(resp); err != nil {
627 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
628 }
629 })
630
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000631 // Handler for /screenshot/{id} - serves screenshot images
632 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
633 if r.Method != http.MethodGet {
634 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
635 return
636 }
637
638 // Extract the screenshot ID from the path
639 pathParts := strings.Split(r.URL.Path, "/")
640 if len(pathParts) < 3 {
641 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
642 return
643 }
644
645 screenshotID := pathParts[2]
646
647 // Validate the ID format (prevent directory traversal)
648 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
649 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
650 return
651 }
652
653 // Get the screenshot file path
654 filePath := browse.GetScreenshotPath(screenshotID)
655
656 // Check if the file exists
657 if _, err := os.Stat(filePath); os.IsNotExist(err) {
658 http.Error(w, "Screenshot not found", http.StatusNotFound)
659 return
660 }
661
662 // Serve the file
663 w.Header().Set("Content-Type", "image/png")
664 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
665 http.ServeFile(w, r, filePath)
666 })
667
Earl Lee2e463fb2025-04-17 11:22:22 -0700668 // Handler for POST /chat
669 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
670 if r.Method != http.MethodPost {
671 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
672 return
673 }
674
675 // Parse the request body
676 var requestBody struct {
677 Message string `json:"message"`
678 }
679
680 decoder := json.NewDecoder(r.Body)
681 if err := decoder.Decode(&requestBody); err != nil {
682 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
683 return
684 }
685 defer r.Body.Close()
686
687 if requestBody.Message == "" {
688 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
689 return
690 }
691
692 agent.UserMessage(r.Context(), requestBody.Message)
693
694 w.WriteHeader(http.StatusOK)
695 })
696
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000697 // Handler for POST /upload - uploads a file to /tmp
698 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
699 if r.Method != http.MethodPost {
700 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
701 return
702 }
703
704 // Limit to 10MB file size
705 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
706
707 // Parse the multipart form
708 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
709 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
710 return
711 }
712
713 // Get the file from the multipart form
714 file, handler, err := r.FormFile("file")
715 if err != nil {
716 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
717 return
718 }
719 defer file.Close()
720
721 // Generate a unique ID (8 random bytes converted to 16 hex chars)
722 randBytes := make([]byte, 8)
723 if _, err := rand.Read(randBytes); err != nil {
724 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
725 return
726 }
727
728 // Get file extension from the original filename
729 ext := filepath.Ext(handler.Filename)
730
731 // Create a unique filename in the /tmp directory
732 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
733
734 // Create the destination file
735 destFile, err := os.Create(filename)
736 if err != nil {
737 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
738 return
739 }
740 defer destFile.Close()
741
742 // Copy the file contents to the destination file
743 if _, err := io.Copy(destFile, file); err != nil {
744 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
745 return
746 }
747
748 // Return the path to the saved file
749 w.Header().Set("Content-Type", "application/json")
750 json.NewEncoder(w).Encode(map[string]string{"path": filename})
751 })
752
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700753 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
754 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
755
756 // Handler for /git/push - handles git push operations
757 s.mux.HandleFunc("/git/push", s.handleGitPush)
758
Earl Lee2e463fb2025-04-17 11:22:22 -0700759 // Handler for /cancel - cancels the current inner loop in progress
760 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
761 if r.Method != http.MethodPost {
762 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
763 return
764 }
765
766 // Parse the request body (optional)
767 var requestBody struct {
768 Reason string `json:"reason"`
769 ToolCallID string `json:"tool_call_id"`
770 }
771
772 decoder := json.NewDecoder(r.Body)
773 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
774 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
775 return
776 }
777 defer r.Body.Close()
778
779 cancelReason := "user requested cancellation"
780 if requestBody.Reason != "" {
781 cancelReason = requestBody.Reason
782 }
783
784 if requestBody.ToolCallID != "" {
785 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
786 if err != nil {
787 http.Error(w, err.Error(), http.StatusBadRequest)
788 return
789 }
790 // Return a success response
791 w.Header().Set("Content-Type", "application/json")
792 json.NewEncoder(w).Encode(map[string]string{
793 "status": "cancelled",
794 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700795 "reason": cancelReason,
796 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 return
798 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000799 // Call the CancelTurn method
800 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700801 // Return a success response
802 w.Header().Set("Content-Type", "application/json")
803 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
804 })
805
Pokey Rule397871d2025-05-19 15:02:45 +0100806 // Handler for /end - shuts down the inner sketch process
807 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
808 if r.Method != http.MethodPost {
809 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
810 return
811 }
812
813 // Parse the request body (optional)
814 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700815 Reason string `json:"reason"`
816 Happy *bool `json:"happy,omitempty"`
817 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100818 }
819
820 decoder := json.NewDecoder(r.Body)
821 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
822 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
823 return
824 }
825 defer r.Body.Close()
826
827 endReason := "user requested end of session"
828 if requestBody.Reason != "" {
829 endReason = requestBody.Reason
830 }
831
832 // Send success response before exiting
833 w.Header().Set("Content-Type", "application/json")
834 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
835 if f, ok := w.(http.Flusher); ok {
836 f.Flush()
837 }
838
839 // Log that we're shutting down
840 slog.Info("Ending session", "reason", endReason)
841
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000842 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100843 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000844 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100845 os.Exit(0)
846 }()
847 })
848
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700849 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700850 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
851 debugMux.ServeHTTP(w, r)
852 })
853
854 return s, nil
855}
856
857// Utility functions
858func getHostname() string {
859 hostname, err := os.Hostname()
860 if err != nil {
861 return "unknown"
862 }
863 return hostname
864}
865
866func getWorkingDir() string {
867 wd, err := os.Getwd()
868 if err != nil {
869 return "unknown"
870 }
871 return wd
872}
873
874// createTerminalSession creates a new terminal session with the given ID
875func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
876 // Start a new shell process
877 shellPath := getShellPath()
878 cmd := exec.Command(shellPath)
879
880 // Get working directory from the agent if possible
881 workDir := getWorkingDir()
882 cmd.Dir = workDir
883
884 // Set up environment
885 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
886
887 // Start the command with a pty
888 ptmx, err := pty.Start(cmd)
889 if err != nil {
890 slog.Error("Failed to start pty", "error", err)
891 return nil, err
892 }
893
894 // Create the terminal session
895 session := &terminalSession{
896 pty: ptmx,
897 eventsClients: make(map[chan []byte]bool),
898 cmd: cmd,
899 }
900
901 // Start goroutine to read from pty and broadcast to all connected SSE clients
902 go s.readFromPtyAndBroadcast(sessionID, session)
903
904 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000905}
906
907// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700908func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
909 // Check if the session exists, if not, create it
910 s.ptyMutex.Lock()
911 session, exists := s.terminalSessions[sessionID]
912
913 if !exists {
914 // Create a new terminal session
915 var err error
916 session, err = s.createTerminalSession(sessionID)
917 if err != nil {
918 s.ptyMutex.Unlock()
919 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
920 return
921 }
922
923 // Store the new session
924 s.terminalSessions[sessionID] = session
925 }
926 s.ptyMutex.Unlock()
927
928 // Set headers for SSE
929 w.Header().Set("Content-Type", "text/event-stream")
930 w.Header().Set("Cache-Control", "no-cache")
931 w.Header().Set("Connection", "keep-alive")
932 w.Header().Set("Access-Control-Allow-Origin", "*")
933
934 // Create a channel for this client
935 events := make(chan []byte, 4096) // Buffer to prevent blocking
936
937 // Register this client's channel
938 session.eventsClientsMutex.Lock()
939 clientID := session.lastEventClientID + 1
940 session.lastEventClientID = clientID
941 session.eventsClients[events] = true
942 session.eventsClientsMutex.Unlock()
943
944 // When the client disconnects, remove their channel
945 defer func() {
946 session.eventsClientsMutex.Lock()
947 delete(session.eventsClients, events)
948 close(events)
949 session.eventsClientsMutex.Unlock()
950 }()
951
952 // Flush to send headers to client immediately
953 if f, ok := w.(http.Flusher); ok {
954 f.Flush()
955 }
956
957 // Send events to the client as they arrive
958 for {
959 select {
960 case <-r.Context().Done():
961 return
962 case data := <-events:
963 // Format as SSE with base64 encoding
964 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
965
966 // Flush the data immediately
967 if f, ok := w.(http.Flusher); ok {
968 f.Flush()
969 }
970 }
971 }
972}
973
974// handleTerminalInput processes input to the terminal
975func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
976 // Check if the session exists
977 s.ptyMutex.Lock()
978 session, exists := s.terminalSessions[sessionID]
979 s.ptyMutex.Unlock()
980
981 if !exists {
982 http.Error(w, "Terminal session not found", http.StatusNotFound)
983 return
984 }
985
986 // Read the request body (terminal input or resize command)
987 body, err := io.ReadAll(r.Body)
988 if err != nil {
989 http.Error(w, "Failed to read request body", http.StatusBadRequest)
990 return
991 }
992
993 // Check if it's a resize message
994 if len(body) > 0 && body[0] == '{' {
995 var msg TerminalMessage
996 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
997 if msg.Cols > 0 && msg.Rows > 0 {
998 pty.Setsize(session.pty, &pty.Winsize{
999 Cols: msg.Cols,
1000 Rows: msg.Rows,
1001 })
1002
1003 // Respond with success
1004 w.WriteHeader(http.StatusOK)
1005 return
1006 }
1007 }
1008 }
1009
1010 // Regular terminal input
1011 _, err = session.pty.Write(body)
1012 if err != nil {
1013 slog.Error("Failed to write to pty", "error", err)
1014 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
1015 return
1016 }
1017
1018 // Respond with success
1019 w.WriteHeader(http.StatusOK)
1020}
1021
1022// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1023func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1024 buf := make([]byte, 4096)
1025 defer func() {
1026 // Clean up when done
1027 s.ptyMutex.Lock()
1028 delete(s.terminalSessions, sessionID)
1029 s.ptyMutex.Unlock()
1030
1031 // Close the PTY
1032 session.pty.Close()
1033
1034 // Ensure process is terminated
1035 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001036 session.cmd.Process.Kill()
1037 }
David Crawshawb8431462025-07-09 13:10:32 +10001038 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001039
1040 // Close all client channels
1041 session.eventsClientsMutex.Lock()
1042 for ch := range session.eventsClients {
1043 delete(session.eventsClients, ch)
1044 close(ch)
1045 }
1046 session.eventsClientsMutex.Unlock()
1047 }()
1048
1049 for {
1050 n, err := session.pty.Read(buf)
1051 if err != nil {
1052 if err != io.EOF {
1053 slog.Error("Failed to read from pty", "error", err)
1054 }
1055 break
1056 }
1057
1058 // Make a copy of the data for each client
1059 data := make([]byte, n)
1060 copy(data, buf[:n])
1061
1062 // Broadcast to all connected clients
1063 session.eventsClientsMutex.Lock()
1064 for ch := range session.eventsClients {
1065 // Try to send, but don't block if channel is full
1066 select {
1067 case ch <- data:
1068 default:
1069 // Channel is full, drop the message for this client
1070 }
1071 }
1072 session.eventsClientsMutex.Unlock()
1073 }
1074}
1075
1076// getShellPath returns the path to the shell to use
1077func getShellPath() string {
1078 // Try to use the user's preferred shell
1079 shell := os.Getenv("SHELL")
1080 if shell != "" {
1081 return shell
1082 }
1083
1084 // Default to bash on Unix-like systems
1085 if _, err := os.Stat("/bin/bash"); err == nil {
1086 return "/bin/bash"
1087 }
1088
1089 // Fall back to sh
1090 return "/bin/sh"
1091}
1092
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001093func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001095 build := "unknown build"
1096 bi, ok := debug.ReadBuildInfo()
1097 if ok {
1098 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1099 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1101 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001102 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 fmt.Fprintf(w, `<!doctype html>
1104 <html><head><title>sketch debug</title></head><body>
1105 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001106 pid %d<br>
1107 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001109 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1110 <li><a href="pprof/profile">pprof/profile</a></li>
1111 <li><a href="pprof/symbol">pprof/symbol</a></li>
1112 <li><a href="pprof/trace">pprof/trace</a></li>
1113 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001114 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 </ul>
1116 </body>
1117 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001118 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001119 })
1120 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1121 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1122 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1123 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1124 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001125
1126 // Add conversation history debug handler
1127 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1128 w.Header().Set("Content-Type", "application/json")
1129
1130 // Use type assertion to access the GetConvo method
1131 type ConvoProvider interface {
1132 GetConvo() loop.ConvoInterface
1133 }
1134
1135 if convoProvider, ok := agent.(ConvoProvider); ok {
1136 // Call the DebugJSON method to get the conversation history
1137 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1138 if err != nil {
1139 http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
1140 return
1141 }
1142
1143 // Write the JSON response
1144 w.Write(historyJSON)
1145 } else {
1146 http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
1147 }
1148 })
1149
Earl Lee2e463fb2025-04-17 11:22:22 -07001150 return mux
1151}
1152
1153// isValidGitSHA validates if a string looks like a valid git SHA hash.
1154// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1155func isValidGitSHA(sha string) bool {
1156 // Git SHA must be a hexadecimal string with at least 4 characters
1157 if len(sha) < 4 || len(sha) > 40 {
1158 return false
1159 }
1160
1161 // Check if the string only contains hexadecimal characters
1162 for _, char := range sha {
1163 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1164 return false
1165 }
1166 }
1167
1168 return true
1169}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001170
1171// /stream?from=N endpoint for Server-Sent Events
1172func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1173 w.Header().Set("Content-Type", "text/event-stream")
1174 w.Header().Set("Cache-Control", "no-cache")
1175 w.Header().Set("Connection", "keep-alive")
1176 w.Header().Set("Access-Control-Allow-Origin", "*")
1177
1178 // Extract the 'from' parameter
1179 fromParam := r.URL.Query().Get("from")
1180 var fromIndex int
1181 var err error
1182 if fromParam != "" {
1183 fromIndex, err = strconv.Atoi(fromParam)
1184 if err != nil {
1185 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1186 return
1187 }
1188 }
1189
1190 // Ensure 'from' is valid
1191 currentCount := s.agent.MessageCount()
1192 if fromIndex < 0 {
1193 fromIndex = 0
1194 } else if fromIndex > currentCount {
1195 fromIndex = currentCount
1196 }
1197
1198 // Send the current state immediately
1199 state := s.getState()
1200
1201 // Create JSON encoder
1202 encoder := json.NewEncoder(w)
1203
1204 // Send state as an event
1205 fmt.Fprintf(w, "event: state\n")
1206 fmt.Fprintf(w, "data: ")
1207 encoder.Encode(state)
1208 fmt.Fprintf(w, "\n\n")
1209
1210 if f, ok := w.(http.Flusher); ok {
1211 f.Flush()
1212 }
1213
1214 // Create a context for the SSE stream
1215 ctx := r.Context()
1216
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001217 // Setup heartbeat timer
1218 heartbeatTicker := time.NewTicker(45 * time.Second)
1219 defer heartbeatTicker.Stop()
1220
1221 // Create a channel for messages
1222 messageChan := make(chan *loop.AgentMessage, 10)
1223
Philip Zeyligereab12de2025-05-14 02:35:53 +00001224 // Create a channel for state transitions
1225 stateChan := make(chan *loop.StateTransition, 10)
1226
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001227 // Start a goroutine to read messages without blocking the heartbeat
1228 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001229 // Create an iterator to receive new messages as they arrive
1230 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1231 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001232 defer close(messageChan)
1233 for {
1234 // This can block, but it's in its own goroutine
1235 newMessage := iterator.Next()
1236 if newMessage == nil {
1237 // No message available (likely due to context cancellation)
1238 slog.InfoContext(ctx, "No more messages available, ending message stream")
1239 return
1240 }
1241
1242 select {
1243 case messageChan <- newMessage:
1244 // Message sent to channel
1245 case <-ctx.Done():
1246 // Context cancelled
1247 return
1248 }
1249 }
1250 }()
1251
Philip Zeyligereab12de2025-05-14 02:35:53 +00001252 // Start a goroutine to read state transitions
1253 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001254 // Create an iterator to receive state transitions
1255 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1256 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001257 defer close(stateChan)
1258 for {
1259 // This can block, but it's in its own goroutine
1260 newTransition := stateIterator.Next()
1261 if newTransition == nil {
1262 // No transition available (likely due to context cancellation)
1263 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1264 return
1265 }
1266
1267 select {
1268 case stateChan <- newTransition:
1269 // Transition sent to channel
1270 case <-ctx.Done():
1271 // Context cancelled
1272 return
1273 }
1274 }
1275 }()
1276
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001277 // Stay connected and stream real-time updates
1278 for {
1279 select {
1280 case <-heartbeatTicker.C:
1281 // Send heartbeat event
1282 fmt.Fprintf(w, "event: heartbeat\n")
1283 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1284
1285 // Flush to send the heartbeat immediately
1286 if f, ok := w.(http.Flusher); ok {
1287 f.Flush()
1288 }
1289
1290 case <-ctx.Done():
1291 // Client disconnected
1292 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1293 return
1294
Philip Zeyligereab12de2025-05-14 02:35:53 +00001295 case _, ok := <-stateChan:
1296 if !ok {
1297 // Channel closed
1298 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1299 return
1300 }
1301
1302 // Get updated state
1303 state = s.getState()
1304
1305 // Send updated state after the state transition
1306 fmt.Fprintf(w, "event: state\n")
1307 fmt.Fprintf(w, "data: ")
1308 encoder.Encode(state)
1309 fmt.Fprintf(w, "\n\n")
1310
1311 // Flush to send the state immediately
1312 if f, ok := w.(http.Flusher); ok {
1313 f.Flush()
1314 }
1315
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001316 case newMessage, ok := <-messageChan:
1317 if !ok {
1318 // Channel closed
1319 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1320 return
1321 }
1322
1323 // Send the new message as an event
1324 fmt.Fprintf(w, "event: message\n")
1325 fmt.Fprintf(w, "data: ")
1326 encoder.Encode(newMessage)
1327 fmt.Fprintf(w, "\n\n")
1328
1329 // Get updated state
1330 state = s.getState()
1331
1332 // Send updated state after the message
1333 fmt.Fprintf(w, "event: state\n")
1334 fmt.Fprintf(w, "data: ")
1335 encoder.Encode(state)
1336 fmt.Fprintf(w, "\n\n")
1337
1338 // Flush to send the message and state immediately
1339 if f, ok := w.(http.Flusher); ok {
1340 f.Flush()
1341 }
1342 }
1343 }
1344}
1345
1346// Helper function to get the current state
1347func (s *Server) getState() State {
1348 serverMessageCount := s.agent.MessageCount()
1349 totalUsage := s.agent.TotalUsage()
1350
Philip Zeyliger64f60462025-06-16 13:57:10 -07001351 // Get diff stats
1352 diffAdded, diffRemoved := s.agent.DiffStats()
1353
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001354 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001355 StateVersion: 2,
1356 MessageCount: serverMessageCount,
1357 TotalUsage: &totalUsage,
1358 Hostname: s.hostname,
1359 WorkingDir: getWorkingDir(),
1360 // TODO: Rename this field to sketch-base?
1361 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001362 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001363 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001364 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001365 OS: s.agent.OS(),
1366 OutsideHostname: s.agent.OutsideHostname(),
1367 InsideHostname: s.hostname,
1368 OutsideOS: s.agent.OutsideOS(),
1369 InsideOS: s.agent.OS(),
1370 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1371 InsideWorkingDir: getWorkingDir(),
1372 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001373 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001374 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1375 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1376 SessionID: s.agent.SessionID(),
1377 SSHAvailable: s.sshAvailable,
1378 SSHError: s.sshError,
1379 InContainer: s.agent.IsInContainer(),
1380 FirstMessageIndex: s.agent.FirstMessageIndex(),
1381 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001382 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001383 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001384 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001385 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001386 DiffLinesAdded: diffAdded,
1387 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001388 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001389 TokenContextWindow: s.agent.TokenContextWindow(),
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001390 Model: s.agent.ModelName(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001391 }
1392}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001393
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001394// getOpenPorts retrieves the current open ports from the agent
1395func (s *Server) getOpenPorts() []Port {
1396 ports := s.agent.GetPorts()
1397 if ports == nil {
1398 return nil
1399 }
1400
1401 result := make([]Port, len(ports))
1402 for i, port := range ports {
1403 result[i] = Port{
1404 Proto: port.Proto,
1405 Port: port.Port,
1406 Process: port.Process,
1407 Pid: port.Pid,
1408 }
1409 }
1410 return result
1411}
1412
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001413func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1414 if r.Method != "GET" {
1415 w.WriteHeader(http.StatusMethodNotAllowed)
1416 return
1417 }
1418
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001419 // Get the git repository root directory from agent
1420 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001421
1422 // Parse query parameters
1423 query := r.URL.Query()
1424 commit := query.Get("commit")
1425 from := query.Get("from")
1426 to := query.Get("to")
1427
1428 // If commit is specified, use commit^ and commit as from and to
1429 if commit != "" {
1430 from = commit + "^"
1431 to = commit
1432 }
1433
1434 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001435 if from == "" {
1436 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001437 return
1438 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001439 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001440
1441 // Call the git_tools function
1442 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1443 if err != nil {
1444 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1445 return
1446 }
1447
1448 // Return the result as JSON
1449 w.Header().Set("Content-Type", "application/json")
1450 if err := json.NewEncoder(w).Encode(diff); err != nil {
1451 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1452 return
1453 }
1454}
1455
1456func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1457 if r.Method != "GET" {
1458 w.WriteHeader(http.StatusMethodNotAllowed)
1459 return
1460 }
1461
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001462 // Get the git repository root directory from agent
1463 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001464
1465 // Parse query parameters
1466 hash := r.URL.Query().Get("hash")
1467 if hash == "" {
1468 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1469 return
1470 }
1471
1472 // Call the git_tools function
1473 show, err := git_tools.GitShow(repoDir, hash)
1474 if err != nil {
1475 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1476 return
1477 }
1478
1479 // Create a JSON response
1480 response := map[string]string{
1481 "hash": hash,
1482 "output": show,
1483 }
1484
1485 // Return the result as JSON
1486 w.Header().Set("Content-Type", "application/json")
1487 if err := json.NewEncoder(w).Encode(response); err != nil {
1488 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1489 return
1490 }
1491}
1492
1493func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1494 if r.Method != "GET" {
1495 w.WriteHeader(http.StatusMethodNotAllowed)
1496 return
1497 }
1498
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001499 // Get the git repository root directory and initial commit from agent
1500 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001501 initialCommit := s.agent.SketchGitBaseRef()
1502
1503 // Call the git_tools function
1504 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1505 if err != nil {
1506 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1507 return
1508 }
1509
1510 // Return the result as JSON
1511 w.Header().Set("Content-Type", "application/json")
1512 if err := json.NewEncoder(w).Encode(log); err != nil {
1513 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1514 return
1515 }
1516}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001517
1518func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1519 if r.Method != "GET" {
1520 w.WriteHeader(http.StatusMethodNotAllowed)
1521 return
1522 }
1523
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001524 // Get the git repository root directory from agent
1525 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001526
1527 // Parse query parameters
1528 query := r.URL.Query()
1529 path := query.Get("path")
1530
1531 // Check if path is provided
1532 if path == "" {
1533 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1534 return
1535 }
1536
1537 // Get file content using GitCat
1538 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001539 switch {
1540 case err == nil:
1541 // continued below
1542 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001543 w.WriteHeader(http.StatusNoContent)
1544 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001545 default:
1546 http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001547 return
1548 }
1549
1550 // Return the content as JSON for consistency with other endpoints
1551 w.Header().Set("Content-Type", "application/json")
1552 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1553 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1554 return
1555 }
1556}
1557
1558func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1559 if r.Method != "POST" {
1560 w.WriteHeader(http.StatusMethodNotAllowed)
1561 return
1562 }
1563
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001564 // Get the git repository root directory from agent
1565 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001566
1567 // Parse request body
1568 var requestBody struct {
1569 Path string `json:"path"`
1570 Content string `json:"content"`
1571 }
1572
1573 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1574 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1575 return
1576 }
1577 defer r.Body.Close()
1578
1579 // Check if path is provided
1580 if requestBody.Path == "" {
1581 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1582 return
1583 }
1584
1585 // Save file content using GitSaveFile
1586 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1587 if err != nil {
1588 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1589 return
1590 }
1591
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001592 // Auto-commit the changes
1593 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1594 if err != nil {
1595 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1596 return
1597 }
1598
1599 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001600 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1601 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1602 return
1603 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001604
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001605 // Return simple success response
1606 w.WriteHeader(http.StatusOK)
1607 w.Write([]byte("ok"))
1608}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001609
1610func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1611 if r.Method != "GET" {
1612 w.WriteHeader(http.StatusMethodNotAllowed)
1613 return
1614 }
1615
1616 repoDir := s.agent.RepoRoot()
1617 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1618 if err != nil {
1619 http.Error(w, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
1620 return
1621 }
1622
1623 w.Header().Set("Content-Type", "application/json")
1624 response := map[string][]string{
1625 "untracked_files": untrackedFiles,
1626 }
1627 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1628}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001629
1630// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1631func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1632 if r.Method != "GET" {
1633 w.WriteHeader(http.StatusMethodNotAllowed)
1634 return
1635 }
1636
1637 repoDir := s.agent.RepoRoot()
1638
1639 // Get the current HEAD commit hash and subject in one command
1640 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1641 cmd.Dir = repoDir
1642 output, err := cmd.Output()
1643 if err != nil {
1644 http.Error(w, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
1645 return
1646 }
1647
1648 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1649 if len(parts) != 2 {
1650 http.Error(w, "Unexpected git log output format", http.StatusInternalServerError)
1651 return
1652 }
1653 hash := parts[0]
1654 subject := parts[1]
1655
1656 // Get list of remote names
1657 cmd = exec.Command("git", "remote")
1658 cmd.Dir = repoDir
1659 output, err = cmd.Output()
1660 if err != nil {
1661 http.Error(w, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
1662 return
1663 }
1664
1665 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1666
1667 remotes := make([]Remote, 0, len(remoteNames))
1668
1669 // Get URL and display name for each remote
1670 for _, remoteName := range remoteNames {
1671 cmd = exec.Command("git", "remote", "get-url", remoteName)
1672 cmd.Dir = repoDir
1673 urlOutput, err := cmd.Output()
1674 if err != nil {
1675 // Skip this remote if we can't get its URL
1676 continue
1677 }
1678 url := strings.TrimSpace(string(urlOutput))
1679
1680 // Set display name based on passthrough-upstream and remote name
1681 var displayName string
1682 var isGitHub bool
1683 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1684 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1685 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1686 isGitHub = false
1687 } else if remoteName == "origin" || remoteName == "upstream" {
1688 // Use git_origin value, simplified for GitHub URLs
1689 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1690 } else {
1691 // For other remotes, use the remote URL directly
1692 displayName, isGitHub = simplifyGitHubURL(url)
1693 }
1694
1695 remotes = append(remotes, Remote{
1696 Name: remoteName,
1697 URL: url,
1698 DisplayName: displayName,
1699 IsGitHub: isGitHub,
1700 })
1701 }
1702
1703 w.Header().Set("Content-Type", "application/json")
1704 response := GitPushInfoResponse{
1705 Hash: hash,
1706 Subject: subject,
1707 Remotes: remotes,
1708 }
1709 _ = json.NewEncoder(w).Encode(response)
1710}
1711
1712// handleGitPush handles git push operations
1713func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1714 if r.Method != "POST" {
1715 w.WriteHeader(http.StatusMethodNotAllowed)
1716 return
1717 }
1718
1719 // Parse request body
1720 var requestBody GitPushRequest
1721
1722 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1723 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1724 return
1725 }
1726 defer r.Body.Close()
1727
1728 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
1729 http.Error(w, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
1730 return
1731 }
1732
1733 repoDir := s.agent.RepoRoot()
1734
1735 // Build the git push command
1736 args := []string{"push"}
1737 if requestBody.DryRun {
1738 args = append(args, "--dry-run")
1739 }
1740 if requestBody.Force {
1741 args = append(args, "--force")
1742 }
1743
1744 // Determine the target refspec
1745 var targetRef string
1746 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1747 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1748 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1749 } else {
1750 // Normal case: push to refs/heads/<branch>
1751 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1752 }
1753
1754 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1755
1756 // Log the git push command being executed
1757 slog.InfoContext(r.Context(), "executing git push command",
1758 "command", "git",
1759 "args", args,
1760 "remote", requestBody.Remote,
1761 "branch", requestBody.Branch,
1762 "commit", requestBody.Commit,
1763 "target_ref", targetRef,
1764 "dry_run", requestBody.DryRun,
1765 "force", requestBody.Force,
1766 "repo_dir", repoDir)
1767
1768 cmd := exec.Command("git", args...)
1769 cmd.Dir = repoDir
1770 // Ideally we want to pass an extra HTTP header so that the
1771 // server can know that this was likely a user initiated action
1772 // and not an agent-initiated action. However, git push weirdly
1773 // doesn't take a "-c" option, and the only handy env variable that
1774 // because a header is the user agent, so we abuse it...
1775 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1776 output, err := cmd.CombinedOutput()
1777
1778 // Log the result of the git push command
1779 if err != nil {
1780 slog.WarnContext(r.Context(), "git push command failed",
1781 "error", err,
1782 "output", string(output),
1783 "args", args)
1784 } else {
1785 slog.InfoContext(r.Context(), "git push command completed successfully",
1786 "output", string(output),
1787 "args", args)
1788 }
1789
1790 // Prepare response
1791 response := GitPushResponse{
1792 Success: err == nil,
1793 Output: string(output),
1794 DryRun: requestBody.DryRun,
1795 }
1796
1797 if err != nil {
1798 response.Error = err.Error()
1799 }
1800
1801 w.Header().Set("Content-Type", "application/json")
1802 _ = json.NewEncoder(w).Encode(response)
1803}