blob: 11dd045063f44c4d4420c56216a724d47038bd6c [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
Philip Zeyligere34ffd62025-07-25 13:20:49 -070070// httpError logs the error and sends an HTTP error response
71func httpError(w http.ResponseWriter, r *http.Request, message string, code int) {
72 slog.Error("HTTP error", "method", r.Method, "path", r.URL.Path, "message", message, "code", code)
73 http.Error(w, message, code)
74}
75
Philip Zeyliger254c49f2025-07-17 17:26:24 -070076// isGitHubURL checks if a URL is a GitHub URL
77func isGitHubURL(url string) bool {
78 return strings.Contains(url, "github.com")
79}
80
81// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
82// and also returns whether it's a github url
83func simplifyGitHubURL(url string) (string, bool) {
84 // Handle GitHub URLs in various formats
85 if strings.Contains(url, "github.com") {
86 // Extract owner/repo from URLs like:
87 // https://github.com/owner/repo.git
88 // git@github.com:owner/repo.git
89 // https://github.com/owner/repo
90 re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
91 if matches := re.FindStringSubmatch(url); len(matches) > 1 {
92 return matches[1], true
93 }
94 }
95 return url, false
96}
97
Earl Lee2e463fb2025-04-17 11:22:22 -070098// terminalSession represents a terminal session with its PTY and the event channel
99type terminalSession struct {
100 pty *os.File
101 eventsClients map[chan []byte]bool
102 lastEventClientID int
103 eventsClientsMutex sync.Mutex
104 cmd *exec.Cmd
105}
106
107// TerminalMessage represents a message sent from the client for terminal resize events
108type TerminalMessage struct {
109 Type string `json:"type"`
110 Cols uint16 `json:"cols"`
111 Rows uint16 `json:"rows"`
112}
113
114// TerminalResponse represents the response for a new terminal creation
115type TerminalResponse struct {
116 SessionID string `json:"sessionId"`
117}
118
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700119// TodoItem represents a single todo item for task management
120type TodoItem struct {
121 ID string `json:"id"`
122 Task string `json:"task"`
123 Status string `json:"status"` // queued, in-progress, completed
124}
125
126// TodoList represents a collection of todo items
127type TodoList struct {
128 Items []TodoItem `json:"items"`
129}
130
Sean McCulloughd9f13372025-04-21 15:08:49 -0700131type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700132 // null or 1: "old"
133 // 2: supports SSE for message updates
134 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700135 MessageCount int `json:"message_count"`
136 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
137 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700138 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700139 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000140 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700141 Hostname string `json:"hostname"` // deprecated
142 WorkingDir string `json:"working_dir"` // deprecated
143 OS string `json:"os"` // deprecated
144 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +0000145 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700146 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
147 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
148 SessionID string `json:"session_id"`
149 SSHAvailable bool `json:"ssh_available"`
150 SSHError string `json:"ssh_error,omitempty"`
151 InContainer bool `json:"in_container"`
152 FirstMessageIndex int `json:"first_message_index"`
153 AgentState string `json:"agent_state,omitempty"`
154 OutsideHostname string `json:"outside_hostname,omitempty"`
155 InsideHostname string `json:"inside_hostname,omitempty"`
156 OutsideOS string `json:"outside_os,omitempty"`
157 InsideOS string `json:"inside_os,omitempty"`
158 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
159 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700160 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
161 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
162 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
163 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700164 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
165 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000166 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
banksean5ab8fb82025-07-09 12:34:55 -0700167 TokenContextWindow int `json:"token_context_window,omitempty"`
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000168 Model string `json:"model,omitempty"` // Name of the model being used
bankseanc67d7bc2025-07-23 10:59:02 -0700169 SessionEnded bool `json:"session_ended,omitempty"`
170 CanSendMessages bool `json:"can_send_messages,omitempty"`
171 EndedAt time.Time `json:"ended_at,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000172}
173
174// Port represents an open TCP port
175type Port struct {
176 Proto string `json:"proto"` // "tcp" or "udp"
177 Port uint16 `json:"port"` // port number
178 Process string `json:"process"` // optional process name
179 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700180}
181
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700182type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700183 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
184 HostAddr string `json:"host_addr"`
185
186 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000187 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
188 SSHServerIdentity []byte `json:"ssh_server_identity"`
189 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
190 SSHHostCertificate []byte `json:"ssh_host_certificate"`
191 SSHAvailable bool `json:"ssh_available"`
192 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700193}
194
Earl Lee2e463fb2025-04-17 11:22:22 -0700195// Server serves sketch HTTP. Server implements http.Handler.
196type Server struct {
197 mux *http.ServeMux
198 agent loop.CodingAgent
199 hostname string
200 logFile *os.File
201 // Mutex to protect terminalSessions
202 ptyMutex sync.Mutex
203 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000204 sshAvailable bool
205 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700206}
207
208func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000209 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
210 if port := s.ParsePortProxyHost(r.Host); port != "" {
211 s.proxyToPort(w, r, port)
212 return
213 }
214
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 s.mux.ServeHTTP(w, r)
216}
217
Philip Zeyligera9710d72025-07-02 02:50:14 +0000218// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
219func (s *Server) ParsePortProxyHost(host string) string {
220 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
221 hostname := host
222 if idx := strings.LastIndex(host, ":"); idx > 0 {
223 hostname = host[:idx]
224 }
225
226 // Check if hostname matches p<port>.localhost pattern
227 if strings.HasSuffix(hostname, ".localhost") {
228 prefix := strings.TrimSuffix(hostname, ".localhost")
229 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
230 port := prefix[1:] // Remove 'p' prefix
231 // Basic validation - port should be numeric and in valid range
232 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
233 return port
234 }
235 }
236 }
237
238 return ""
239}
240
241// proxyToPort proxies the request to localhost:<port>
242func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
243 // Create a reverse proxy to localhost:<port>
244 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
245 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700246 httpError(w, r, "Failed to parse proxy target", http.StatusInternalServerError)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000247 return
248 }
249
250 proxy := httputil.NewSingleHostReverseProxy(target)
251
252 // Customize the Director to modify the request
253 originalDirector := proxy.Director
254 proxy.Director = func(req *http.Request) {
255 originalDirector(req)
256 // Set the target host
257 req.URL.Host = target.Host
258 req.URL.Scheme = target.Scheme
259 req.Host = target.Host
260 }
261
262 // Handle proxy errors
263 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
264 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700265 httpError(w, r, "Proxy error: "+err.Error(), http.StatusBadGateway)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000266 }
267
268 proxy.ServeHTTP(w, r)
269}
270
Earl Lee2e463fb2025-04-17 11:22:22 -0700271// New creates a new HTTP server.
272func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
273 s := &Server{
274 mux: http.NewServeMux(),
275 agent: agent,
276 hostname: getHostname(),
277 logFile: logFile,
278 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000279 sshAvailable: false,
280 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700281 }
282
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000283 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000284
285 // Git tool endpoints
286 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
287 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700288 s.mux.HandleFunc("/git/cat", s.handleGitCat)
289 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000290 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000291 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000292
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
294 // Check if a specific commit hash was requested
295 commit := r.URL.Query().Get("commit")
296
297 // Get the diff, optionally for a specific commit
298 var diff string
299 var err error
300 if commit != "" {
301 // Validate the commit hash format
302 if !isValidGitSHA(commit) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700303 httpError(w, r, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700304 return
305 }
306
307 diff, err = agent.Diff(&commit)
308 } else {
309 diff, err = agent.Diff(nil)
310 }
311
312 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700313 httpError(w, r, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700314 return
315 }
316
317 w.Header().Set("Content-Type", "text/plain")
318 w.Write([]byte(diff))
319 })
320
321 // Handler for initialization called by host sketch binary when inside docker.
322 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
323 defer func() {
324 if err := recover(); err != nil {
325 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
326
327 // Return an error response to the client
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700328 httpError(w, r, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 }
330 }()
331
332 if r.Method != "POST" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700333 httpError(w, r, "POST required", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700334 return
335 }
336
337 body, err := io.ReadAll(r.Body)
338 r.Body.Close()
339 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700340 httpError(w, r, "failed to read request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700341 return
342 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700343
344 m := &InitRequest{}
345 if err := json.Unmarshal(body, m); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700346 httpError(w, r, "bad request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700347 return
348 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700349
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000350 // Store SSH availability info
351 s.sshAvailable = m.SSHAvailable
352 s.sshError = m.SSHError
353
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700354 // Start the SSH server if the init request included ssh keys.
355 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
356 go func() {
357 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000358 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700359 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000360 // Update SSH error if server fails to start
361 s.sshAvailable = false
362 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700363 }
364 }()
365 }
366
Earl Lee2e463fb2025-04-17 11:22:22 -0700367 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700368 InDocker: true,
369 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700370 }
371 if err := agent.Init(ini); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700372 httpError(w, r, "init failed: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700373 return
374 }
375 w.Header().Set("Content-Type", "application/json")
376 io.WriteString(w, "{}\n")
377 })
378
379 // Handler for /messages?start=N&end=M (start/end are optional)
380 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
381 w.Header().Set("Content-Type", "application/json")
382
383 // Extract query parameters for range
384 var start, end int
385 var err error
386
387 currentCount := agent.MessageCount()
388
389 startParam := r.URL.Query().Get("start")
390 if startParam != "" {
391 start, err = strconv.Atoi(startParam)
392 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700393 httpError(w, r, "Invalid 'start' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700394 return
395 }
396 }
397
398 endParam := r.URL.Query().Get("end")
399 if endParam != "" {
400 end, err = strconv.Atoi(endParam)
401 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700402 httpError(w, r, "Invalid 'end' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700403 return
404 }
405 } else {
406 end = currentCount
407 }
408
409 if start < 0 || start > end || end > currentCount {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700410 httpError(w, r, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700411 return
412 }
413
414 start = max(0, start)
415 end = min(agent.MessageCount(), end)
416 messages := agent.Messages(start, end)
417
418 // Create a JSON encoder with indentation for pretty-printing
419 encoder := json.NewEncoder(w)
420 encoder.SetIndent("", " ") // Two spaces for each indentation level
421
422 err = encoder.Encode(messages)
423 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700424 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 }
426 })
427
428 // Handler for /logs - displays the contents of the log file
429 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
430 if s.logFile == nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700431 httpError(w, r, "log file not set", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700432 return
433 }
434 logContents, err := os.ReadFile(s.logFile.Name())
435 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700436 httpError(w, r, "error reading log file: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700437 return
438 }
439 w.Header().Set("Content-Type", "text/html; charset=utf-8")
440 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
441 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
442 fmt.Fprintf(w, "</body>\n</html>")
443 })
444
445 // Handler for /download - downloads both messages and status as a JSON file
446 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
447 // Set headers for file download
448 w.Header().Set("Content-Type", "application/octet-stream")
449
450 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
451 timestamp := time.Now().Format("20060102-150405")
452 filename := fmt.Sprintf("sketch-%s.json", timestamp)
453
454 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
455
456 // Get all messages
457 messageCount := agent.MessageCount()
458 messages := agent.Messages(0, messageCount)
459
460 // Get status information (usage and other metadata)
461 totalUsage := agent.TotalUsage()
462 hostname := getHostname()
463 workingDir := getWorkingDir()
464
465 // Create a combined structure with all information
466 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700467 Messages []loop.AgentMessage `json:"messages"`
468 MessageCount int `json:"message_count"`
469 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
470 Hostname string `json:"hostname"`
471 WorkingDir string `json:"working_dir"`
472 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700473 }{
474 Messages: messages,
475 MessageCount: messageCount,
476 TotalUsage: totalUsage,
477 Hostname: hostname,
478 WorkingDir: workingDir,
479 DownloadTime: time.Now().Format(time.RFC3339),
480 }
481
482 // Marshal the JSON with indentation for better readability
483 jsonData, err := json.MarshalIndent(downloadData, "", " ")
484 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700485 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700486 return
487 }
488 w.Write(jsonData)
489 })
490
491 // The latter doesn't return until the number of messages has changed (from seen
492 // or from when this was called.)
493 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
494 pollParam := r.URL.Query().Get("poll")
495 seenParam := r.URL.Query().Get("seen")
496
497 // Get the client's current message count (if provided)
498 clientMessageCount := -1
499 var err error
500 if seenParam != "" {
501 clientMessageCount, err = strconv.Atoi(seenParam)
502 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700503 httpError(w, r, "Invalid 'seen' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700504 return
505 }
506 }
507
508 serverMessageCount := agent.MessageCount()
509
510 // Let lazy clients not have to specify this.
511 if clientMessageCount == -1 {
512 clientMessageCount = serverMessageCount
513 }
514
515 if pollParam == "true" {
516 ch := make(chan string)
517 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700518 it := agent.NewIterator(r.Context(), clientMessageCount)
519 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700520 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700521 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700522 }()
523 select {
524 case <-r.Context().Done():
525 slog.DebugContext(r.Context(), "abandoned poll request")
526 return
527 case <-time.After(90 * time.Second):
528 // Let the user call /state again to get the latest to limit how long our long polls hang out.
529 slog.DebugContext(r.Context(), "longish poll request")
530 break
531 case <-ch:
532 break
533 }
534 }
535
Earl Lee2e463fb2025-04-17 11:22:22 -0700536 w.Header().Set("Content-Type", "application/json")
537
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000538 // Use the shared getState function
539 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700540
541 // Create a JSON encoder with indentation for pretty-printing
542 encoder := json.NewEncoder(w)
543 encoder.SetIndent("", " ") // Two spaces for each indentation level
544
545 err = encoder.Encode(state)
546 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700547 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700548 }
549 })
550
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700551 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700552
553 // Terminal WebSocket handler
554 // Terminal endpoints - predefined terminals 1-9
555 // TODO: The UI doesn't actually know how to use terminals 2-9!
556 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
557 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700558 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700559 return
560 }
561 pathParts := strings.Split(r.URL.Path, "/")
562 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700563 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700564 return
565 }
566
567 sessionID := pathParts[3]
568 // Validate that the terminal ID is between 1-9
569 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700570 httpError(w, r, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700571 return
572 }
573
574 s.handleTerminalEvents(w, r, sessionID)
575 })
576
577 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
578 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700579 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700580 return
581 }
582 pathParts := strings.Split(r.URL.Path, "/")
583 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700584 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700585 return
586 }
587 sessionID := pathParts[3]
588 s.handleTerminalInput(w, r, sessionID)
589 })
590
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700591 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700592 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700593 webuiFS := embedded.WebUIFS()
594 appShell := "sketch-app-shell.html"
595 if r.URL.Query().Has("m") {
596 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700597 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700598 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700599 })
600
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700601 // Handler for /commit-description - returns the description of a git commit
602 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
603 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700604 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700605 return
606 }
607
608 // Get the revision parameter
609 revision := r.URL.Query().Get("revision")
610 if revision == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700611 httpError(w, r, "Missing revision parameter", http.StatusBadRequest)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700612 return
613 }
614
615 // Run git command to get commit description
616 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
617 // Use the working directory from the agent
618 cmd.Dir = s.agent.WorkingDir()
619
620 output, err := cmd.CombinedOutput()
621 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700622 httpError(w, r, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700623 return
624 }
625
626 // Prepare the response
627 resp := map[string]string{
628 "description": strings.TrimSpace(string(output)),
629 }
630
631 w.Header().Set("Content-Type", "application/json")
632 if err := json.NewEncoder(w).Encode(resp); err != nil {
633 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
634 }
635 })
636
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000637 // Handler for /screenshot/{id} - serves screenshot images
638 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
639 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700640 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000641 return
642 }
643
644 // Extract the screenshot ID from the path
645 pathParts := strings.Split(r.URL.Path, "/")
646 if len(pathParts) < 3 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700647 httpError(w, r, "Invalid screenshot ID", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000648 return
649 }
650
651 screenshotID := pathParts[2]
652
653 // Validate the ID format (prevent directory traversal)
654 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700655 httpError(w, r, "Invalid screenshot ID format", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000656 return
657 }
658
659 // Get the screenshot file path
660 filePath := browse.GetScreenshotPath(screenshotID)
661
662 // Check if the file exists
663 if _, err := os.Stat(filePath); os.IsNotExist(err) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700664 httpError(w, r, "Screenshot not found", http.StatusNotFound)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000665 return
666 }
667
668 // Serve the file
669 w.Header().Set("Content-Type", "image/png")
670 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
671 http.ServeFile(w, r, filePath)
672 })
673
Earl Lee2e463fb2025-04-17 11:22:22 -0700674 // Handler for POST /chat
675 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
676 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700677 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700678 return
679 }
680
681 // Parse the request body
682 var requestBody struct {
683 Message string `json:"message"`
684 }
685
686 decoder := json.NewDecoder(r.Body)
687 if err := decoder.Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700688 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700689 return
690 }
691 defer r.Body.Close()
692
693 if requestBody.Message == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700694 httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700695 return
696 }
697
698 agent.UserMessage(r.Context(), requestBody.Message)
699
700 w.WriteHeader(http.StatusOK)
701 })
702
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000703 // Handler for POST /upload - uploads a file to /tmp
704 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
705 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700706 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000707 return
708 }
709
710 // Limit to 10MB file size
711 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
712
713 // Parse the multipart form
714 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700715 httpError(w, r, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000716 return
717 }
718
719 // Get the file from the multipart form
720 file, handler, err := r.FormFile("file")
721 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700722 httpError(w, r, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000723 return
724 }
725 defer file.Close()
726
727 // Generate a unique ID (8 random bytes converted to 16 hex chars)
728 randBytes := make([]byte, 8)
729 if _, err := rand.Read(randBytes); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700730 httpError(w, r, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000731 return
732 }
733
734 // Get file extension from the original filename
735 ext := filepath.Ext(handler.Filename)
736
737 // Create a unique filename in the /tmp directory
738 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
739
740 // Create the destination file
741 destFile, err := os.Create(filename)
742 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700743 httpError(w, r, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000744 return
745 }
746 defer destFile.Close()
747
748 // Copy the file contents to the destination file
749 if _, err := io.Copy(destFile, file); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700750 httpError(w, r, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000751 return
752 }
753
754 // Return the path to the saved file
755 w.Header().Set("Content-Type", "application/json")
756 json.NewEncoder(w).Encode(map[string]string{"path": filename})
757 })
758
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700759 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
760 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
761
762 // Handler for /git/push - handles git push operations
763 s.mux.HandleFunc("/git/push", s.handleGitPush)
764
Earl Lee2e463fb2025-04-17 11:22:22 -0700765 // Handler for /cancel - cancels the current inner loop in progress
766 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
767 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700768 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 return
770 }
771
772 // Parse the request body (optional)
773 var requestBody struct {
774 Reason string `json:"reason"`
775 ToolCallID string `json:"tool_call_id"`
776 }
777
778 decoder := json.NewDecoder(r.Body)
779 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700780 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700781 return
782 }
783 defer r.Body.Close()
784
785 cancelReason := "user requested cancellation"
786 if requestBody.Reason != "" {
787 cancelReason = requestBody.Reason
788 }
789
790 if requestBody.ToolCallID != "" {
791 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
792 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700793 httpError(w, r, err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700794 return
795 }
796 // Return a success response
797 w.Header().Set("Content-Type", "application/json")
798 json.NewEncoder(w).Encode(map[string]string{
799 "status": "cancelled",
800 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700801 "reason": cancelReason,
802 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700803 return
804 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000805 // Call the CancelTurn method
806 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700807 // Return a success response
808 w.Header().Set("Content-Type", "application/json")
809 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
810 })
811
Pokey Rule397871d2025-05-19 15:02:45 +0100812 // Handler for /end - shuts down the inner sketch process
813 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
814 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700815 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Pokey Rule397871d2025-05-19 15:02:45 +0100816 return
817 }
818
819 // Parse the request body (optional)
820 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700821 Reason string `json:"reason"`
822 Happy *bool `json:"happy,omitempty"`
823 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100824 }
825
826 decoder := json.NewDecoder(r.Body)
827 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700828 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Pokey Rule397871d2025-05-19 15:02:45 +0100829 return
830 }
831 defer r.Body.Close()
832
833 endReason := "user requested end of session"
834 if requestBody.Reason != "" {
835 endReason = requestBody.Reason
836 }
837
838 // Send success response before exiting
839 w.Header().Set("Content-Type", "application/json")
840 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
841 if f, ok := w.(http.Flusher); ok {
842 f.Flush()
843 }
844
845 // Log that we're shutting down
846 slog.Info("Ending session", "reason", endReason)
847
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000848 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100849 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000850 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100851 os.Exit(0)
852 }()
853 })
854
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700855 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700856 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
857 debugMux.ServeHTTP(w, r)
858 })
859
860 return s, nil
861}
862
863// Utility functions
864func getHostname() string {
865 hostname, err := os.Hostname()
866 if err != nil {
867 return "unknown"
868 }
869 return hostname
870}
871
872func getWorkingDir() string {
873 wd, err := os.Getwd()
874 if err != nil {
875 return "unknown"
876 }
877 return wd
878}
879
880// createTerminalSession creates a new terminal session with the given ID
881func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
882 // Start a new shell process
883 shellPath := getShellPath()
884 cmd := exec.Command(shellPath)
885
886 // Get working directory from the agent if possible
887 workDir := getWorkingDir()
888 cmd.Dir = workDir
889
890 // Set up environment
891 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
892
893 // Start the command with a pty
894 ptmx, err := pty.Start(cmd)
895 if err != nil {
896 slog.Error("Failed to start pty", "error", err)
897 return nil, err
898 }
899
900 // Create the terminal session
901 session := &terminalSession{
902 pty: ptmx,
903 eventsClients: make(map[chan []byte]bool),
904 cmd: cmd,
905 }
906
907 // Start goroutine to read from pty and broadcast to all connected SSE clients
908 go s.readFromPtyAndBroadcast(sessionID, session)
909
910 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000911}
912
913// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700914func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
915 // Check if the session exists, if not, create it
916 s.ptyMutex.Lock()
917 session, exists := s.terminalSessions[sessionID]
918
919 if !exists {
920 // Create a new terminal session
921 var err error
922 session, err = s.createTerminalSession(sessionID)
923 if err != nil {
924 s.ptyMutex.Unlock()
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700925 httpError(w, r, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700926 return
927 }
928
929 // Store the new session
930 s.terminalSessions[sessionID] = session
931 }
932 s.ptyMutex.Unlock()
933
934 // Set headers for SSE
935 w.Header().Set("Content-Type", "text/event-stream")
936 w.Header().Set("Cache-Control", "no-cache")
937 w.Header().Set("Connection", "keep-alive")
938 w.Header().Set("Access-Control-Allow-Origin", "*")
939
940 // Create a channel for this client
941 events := make(chan []byte, 4096) // Buffer to prevent blocking
942
943 // Register this client's channel
944 session.eventsClientsMutex.Lock()
945 clientID := session.lastEventClientID + 1
946 session.lastEventClientID = clientID
947 session.eventsClients[events] = true
948 session.eventsClientsMutex.Unlock()
949
950 // When the client disconnects, remove their channel
951 defer func() {
952 session.eventsClientsMutex.Lock()
953 delete(session.eventsClients, events)
954 close(events)
955 session.eventsClientsMutex.Unlock()
956 }()
957
958 // Flush to send headers to client immediately
959 if f, ok := w.(http.Flusher); ok {
960 f.Flush()
961 }
962
963 // Send events to the client as they arrive
964 for {
965 select {
966 case <-r.Context().Done():
967 return
968 case data := <-events:
969 // Format as SSE with base64 encoding
970 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
971
972 // Flush the data immediately
973 if f, ok := w.(http.Flusher); ok {
974 f.Flush()
975 }
976 }
977 }
978}
979
980// handleTerminalInput processes input to the terminal
981func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
982 // Check if the session exists
983 s.ptyMutex.Lock()
984 session, exists := s.terminalSessions[sessionID]
985 s.ptyMutex.Unlock()
986
987 if !exists {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700988 httpError(w, r, "Terminal session not found", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700989 return
990 }
991
992 // Read the request body (terminal input or resize command)
993 body, err := io.ReadAll(r.Body)
994 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700995 httpError(w, r, "Failed to read request body", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700996 return
997 }
998
999 // Check if it's a resize message
1000 if len(body) > 0 && body[0] == '{' {
1001 var msg TerminalMessage
1002 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
1003 if msg.Cols > 0 && msg.Rows > 0 {
1004 pty.Setsize(session.pty, &pty.Winsize{
1005 Cols: msg.Cols,
1006 Rows: msg.Rows,
1007 })
1008
1009 // Respond with success
1010 w.WriteHeader(http.StatusOK)
1011 return
1012 }
1013 }
1014 }
1015
1016 // Regular terminal input
1017 _, err = session.pty.Write(body)
1018 if err != nil {
1019 slog.Error("Failed to write to pty", "error", err)
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001020 httpError(w, r, "Failed to write to terminal", http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -07001021 return
1022 }
1023
1024 // Respond with success
1025 w.WriteHeader(http.StatusOK)
1026}
1027
1028// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1029func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1030 buf := make([]byte, 4096)
1031 defer func() {
1032 // Clean up when done
1033 s.ptyMutex.Lock()
1034 delete(s.terminalSessions, sessionID)
1035 s.ptyMutex.Unlock()
1036
1037 // Close the PTY
1038 session.pty.Close()
1039
1040 // Ensure process is terminated
1041 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001042 session.cmd.Process.Kill()
1043 }
David Crawshawb8431462025-07-09 13:10:32 +10001044 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001045
1046 // Close all client channels
1047 session.eventsClientsMutex.Lock()
1048 for ch := range session.eventsClients {
1049 delete(session.eventsClients, ch)
1050 close(ch)
1051 }
1052 session.eventsClientsMutex.Unlock()
1053 }()
1054
1055 for {
1056 n, err := session.pty.Read(buf)
1057 if err != nil {
1058 if err != io.EOF {
1059 slog.Error("Failed to read from pty", "error", err)
1060 }
1061 break
1062 }
1063
1064 // Make a copy of the data for each client
1065 data := make([]byte, n)
1066 copy(data, buf[:n])
1067
1068 // Broadcast to all connected clients
1069 session.eventsClientsMutex.Lock()
1070 for ch := range session.eventsClients {
1071 // Try to send, but don't block if channel is full
1072 select {
1073 case ch <- data:
1074 default:
1075 // Channel is full, drop the message for this client
1076 }
1077 }
1078 session.eventsClientsMutex.Unlock()
1079 }
1080}
1081
1082// getShellPath returns the path to the shell to use
1083func getShellPath() string {
1084 // Try to use the user's preferred shell
1085 shell := os.Getenv("SHELL")
1086 if shell != "" {
1087 return shell
1088 }
1089
1090 // Default to bash on Unix-like systems
1091 if _, err := os.Stat("/bin/bash"); err == nil {
1092 return "/bin/bash"
1093 }
1094
1095 // Fall back to sh
1096 return "/bin/sh"
1097}
1098
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001099func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001101 build := "unknown build"
1102 bi, ok := debug.ReadBuildInfo()
1103 if ok {
1104 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1105 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1107 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001108 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 fmt.Fprintf(w, `<!doctype html>
1110 <html><head><title>sketch debug</title></head><body>
1111 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001112 pid %d<br>
1113 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001115 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1116 <li><a href="pprof/profile">pprof/profile</a></li>
1117 <li><a href="pprof/symbol">pprof/symbol</a></li>
1118 <li><a href="pprof/trace">pprof/trace</a></li>
1119 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001120 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001121 </ul>
1122 </body>
1123 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001124 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001125 })
1126 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1127 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1128 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1129 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1130 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001131
1132 // Add conversation history debug handler
1133 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1134 w.Header().Set("Content-Type", "application/json")
1135
1136 // Use type assertion to access the GetConvo method
1137 type ConvoProvider interface {
1138 GetConvo() loop.ConvoInterface
1139 }
1140
1141 if convoProvider, ok := agent.(ConvoProvider); ok {
1142 // Call the DebugJSON method to get the conversation history
1143 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1144 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001145 httpError(w, r, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001146 return
1147 }
1148
1149 // Write the JSON response
1150 w.Write(historyJSON)
1151 } else {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001152 httpError(w, r, "Agent does not support conversation history debugging", http.StatusNotImplemented)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001153 }
1154 })
1155
Earl Lee2e463fb2025-04-17 11:22:22 -07001156 return mux
1157}
1158
1159// isValidGitSHA validates if a string looks like a valid git SHA hash.
1160// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1161func isValidGitSHA(sha string) bool {
1162 // Git SHA must be a hexadecimal string with at least 4 characters
1163 if len(sha) < 4 || len(sha) > 40 {
1164 return false
1165 }
1166
1167 // Check if the string only contains hexadecimal characters
1168 for _, char := range sha {
1169 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1170 return false
1171 }
1172 }
1173
1174 return true
1175}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001176
1177// /stream?from=N endpoint for Server-Sent Events
1178func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1179 w.Header().Set("Content-Type", "text/event-stream")
1180 w.Header().Set("Cache-Control", "no-cache")
1181 w.Header().Set("Connection", "keep-alive")
1182 w.Header().Set("Access-Control-Allow-Origin", "*")
1183
1184 // Extract the 'from' parameter
1185 fromParam := r.URL.Query().Get("from")
1186 var fromIndex int
1187 var err error
1188 if fromParam != "" {
1189 fromIndex, err = strconv.Atoi(fromParam)
1190 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001191 httpError(w, r, "Invalid 'from' parameter", http.StatusBadRequest)
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001192 return
1193 }
1194 }
1195
1196 // Ensure 'from' is valid
1197 currentCount := s.agent.MessageCount()
1198 if fromIndex < 0 {
1199 fromIndex = 0
1200 } else if fromIndex > currentCount {
1201 fromIndex = currentCount
1202 }
1203
1204 // Send the current state immediately
1205 state := s.getState()
1206
1207 // Create JSON encoder
1208 encoder := json.NewEncoder(w)
1209
1210 // Send state as an event
1211 fmt.Fprintf(w, "event: state\n")
1212 fmt.Fprintf(w, "data: ")
1213 encoder.Encode(state)
1214 fmt.Fprintf(w, "\n\n")
1215
1216 if f, ok := w.(http.Flusher); ok {
1217 f.Flush()
1218 }
1219
1220 // Create a context for the SSE stream
1221 ctx := r.Context()
1222
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001223 // Setup heartbeat timer
1224 heartbeatTicker := time.NewTicker(45 * time.Second)
1225 defer heartbeatTicker.Stop()
1226
1227 // Create a channel for messages
1228 messageChan := make(chan *loop.AgentMessage, 10)
1229
Philip Zeyligereab12de2025-05-14 02:35:53 +00001230 // Create a channel for state transitions
1231 stateChan := make(chan *loop.StateTransition, 10)
1232
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001233 // Start a goroutine to read messages without blocking the heartbeat
1234 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001235 // Create an iterator to receive new messages as they arrive
1236 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1237 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001238 defer close(messageChan)
1239 for {
1240 // This can block, but it's in its own goroutine
1241 newMessage := iterator.Next()
1242 if newMessage == nil {
1243 // No message available (likely due to context cancellation)
1244 slog.InfoContext(ctx, "No more messages available, ending message stream")
1245 return
1246 }
1247
1248 select {
1249 case messageChan <- newMessage:
1250 // Message sent to channel
1251 case <-ctx.Done():
1252 // Context cancelled
1253 return
1254 }
1255 }
1256 }()
1257
Philip Zeyligereab12de2025-05-14 02:35:53 +00001258 // Start a goroutine to read state transitions
1259 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001260 // Create an iterator to receive state transitions
1261 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1262 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001263 defer close(stateChan)
1264 for {
1265 // This can block, but it's in its own goroutine
1266 newTransition := stateIterator.Next()
1267 if newTransition == nil {
1268 // No transition available (likely due to context cancellation)
1269 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1270 return
1271 }
1272
1273 select {
1274 case stateChan <- newTransition:
1275 // Transition sent to channel
1276 case <-ctx.Done():
1277 // Context cancelled
1278 return
1279 }
1280 }
1281 }()
1282
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001283 // Stay connected and stream real-time updates
1284 for {
1285 select {
1286 case <-heartbeatTicker.C:
1287 // Send heartbeat event
1288 fmt.Fprintf(w, "event: heartbeat\n")
1289 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1290
1291 // Flush to send the heartbeat immediately
1292 if f, ok := w.(http.Flusher); ok {
1293 f.Flush()
1294 }
1295
1296 case <-ctx.Done():
1297 // Client disconnected
1298 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1299 return
1300
Philip Zeyligereab12de2025-05-14 02:35:53 +00001301 case _, ok := <-stateChan:
1302 if !ok {
1303 // Channel closed
1304 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1305 return
1306 }
1307
1308 // Get updated state
1309 state = s.getState()
1310
1311 // Send updated state after the state transition
1312 fmt.Fprintf(w, "event: state\n")
1313 fmt.Fprintf(w, "data: ")
1314 encoder.Encode(state)
1315 fmt.Fprintf(w, "\n\n")
1316
1317 // Flush to send the state immediately
1318 if f, ok := w.(http.Flusher); ok {
1319 f.Flush()
1320 }
1321
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001322 case newMessage, ok := <-messageChan:
1323 if !ok {
1324 // Channel closed
1325 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1326 return
1327 }
1328
1329 // Send the new message as an event
1330 fmt.Fprintf(w, "event: message\n")
1331 fmt.Fprintf(w, "data: ")
1332 encoder.Encode(newMessage)
1333 fmt.Fprintf(w, "\n\n")
1334
1335 // Get updated state
1336 state = s.getState()
1337
1338 // Send updated state after the message
1339 fmt.Fprintf(w, "event: state\n")
1340 fmt.Fprintf(w, "data: ")
1341 encoder.Encode(state)
1342 fmt.Fprintf(w, "\n\n")
1343
1344 // Flush to send the message and state immediately
1345 if f, ok := w.(http.Flusher); ok {
1346 f.Flush()
1347 }
1348 }
1349 }
1350}
1351
1352// Helper function to get the current state
1353func (s *Server) getState() State {
1354 serverMessageCount := s.agent.MessageCount()
1355 totalUsage := s.agent.TotalUsage()
1356
Philip Zeyliger64f60462025-06-16 13:57:10 -07001357 // Get diff stats
1358 diffAdded, diffRemoved := s.agent.DiffStats()
1359
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001360 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001361 StateVersion: 2,
1362 MessageCount: serverMessageCount,
1363 TotalUsage: &totalUsage,
1364 Hostname: s.hostname,
1365 WorkingDir: getWorkingDir(),
1366 // TODO: Rename this field to sketch-base?
1367 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001368 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001369 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001370 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001371 OS: s.agent.OS(),
1372 OutsideHostname: s.agent.OutsideHostname(),
1373 InsideHostname: s.hostname,
1374 OutsideOS: s.agent.OutsideOS(),
1375 InsideOS: s.agent.OS(),
1376 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1377 InsideWorkingDir: getWorkingDir(),
1378 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001379 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001380 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1381 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1382 SessionID: s.agent.SessionID(),
1383 SSHAvailable: s.sshAvailable,
1384 SSHError: s.sshError,
1385 InContainer: s.agent.IsInContainer(),
1386 FirstMessageIndex: s.agent.FirstMessageIndex(),
1387 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001388 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001389 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001390 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001391 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001392 DiffLinesAdded: diffAdded,
1393 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001394 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001395 TokenContextWindow: s.agent.TokenContextWindow(),
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001396 Model: s.agent.ModelName(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001397 }
1398}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001399
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001400// getOpenPorts retrieves the current open ports from the agent
1401func (s *Server) getOpenPorts() []Port {
1402 ports := s.agent.GetPorts()
1403 if ports == nil {
1404 return nil
1405 }
1406
1407 result := make([]Port, len(ports))
1408 for i, port := range ports {
1409 result[i] = Port{
1410 Proto: port.Proto,
1411 Port: port.Port,
1412 Process: port.Process,
1413 Pid: port.Pid,
1414 }
1415 }
1416 return result
1417}
1418
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001419func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1420 if r.Method != "GET" {
1421 w.WriteHeader(http.StatusMethodNotAllowed)
1422 return
1423 }
1424
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001425 // Get the git repository root directory from agent
1426 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001427
1428 // Parse query parameters
1429 query := r.URL.Query()
1430 commit := query.Get("commit")
1431 from := query.Get("from")
1432 to := query.Get("to")
1433
1434 // If commit is specified, use commit^ and commit as from and to
1435 if commit != "" {
1436 from = commit + "^"
1437 to = commit
1438 }
1439
1440 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001441 if from == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001442 httpError(w, r, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001443 return
1444 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001445 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001446
1447 // Call the git_tools function
1448 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1449 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001450 httpError(w, r, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001451 return
1452 }
1453
1454 // Return the result as JSON
1455 w.Header().Set("Content-Type", "application/json")
1456 if err := json.NewEncoder(w).Encode(diff); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001457 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001458 return
1459 }
1460}
1461
1462func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1463 if r.Method != "GET" {
1464 w.WriteHeader(http.StatusMethodNotAllowed)
1465 return
1466 }
1467
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001468 // Get the git repository root directory from agent
1469 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001470
1471 // Parse query parameters
1472 hash := r.URL.Query().Get("hash")
1473 if hash == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001474 httpError(w, r, "Missing required parameter: 'hash'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001475 return
1476 }
1477
1478 // Call the git_tools function
1479 show, err := git_tools.GitShow(repoDir, hash)
1480 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001481 httpError(w, r, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001482 return
1483 }
1484
1485 // Create a JSON response
1486 response := map[string]string{
1487 "hash": hash,
1488 "output": show,
1489 }
1490
1491 // Return the result as JSON
1492 w.Header().Set("Content-Type", "application/json")
1493 if err := json.NewEncoder(w).Encode(response); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001494 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001495 return
1496 }
1497}
1498
1499func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1500 if r.Method != "GET" {
1501 w.WriteHeader(http.StatusMethodNotAllowed)
1502 return
1503 }
1504
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001505 // Get the git repository root directory and initial commit from agent
1506 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001507 initialCommit := s.agent.SketchGitBaseRef()
1508
1509 // Call the git_tools function
1510 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1511 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001512 httpError(w, r, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001513 return
1514 }
1515
1516 // Return the result as JSON
1517 w.Header().Set("Content-Type", "application/json")
1518 if err := json.NewEncoder(w).Encode(log); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001519 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001520 return
1521 }
1522}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001523
1524func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1525 if r.Method != "GET" {
1526 w.WriteHeader(http.StatusMethodNotAllowed)
1527 return
1528 }
1529
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001530 // Get the git repository root directory from agent
1531 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001532
1533 // Parse query parameters
1534 query := r.URL.Query()
1535 path := query.Get("path")
1536
1537 // Check if path is provided
1538 if path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001539 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001540 return
1541 }
1542
1543 // Get file content using GitCat
1544 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001545 switch {
1546 case err == nil:
1547 // continued below
1548 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001549 w.WriteHeader(http.StatusNoContent)
1550 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001551 default:
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001552 httpError(w, r, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001553 return
1554 }
1555
1556 // Return the content as JSON for consistency with other endpoints
1557 w.Header().Set("Content-Type", "application/json")
1558 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001559 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001560 return
1561 }
1562}
1563
1564func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1565 if r.Method != "POST" {
1566 w.WriteHeader(http.StatusMethodNotAllowed)
1567 return
1568 }
1569
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001570 // Get the git repository root directory from agent
1571 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001572
1573 // Parse request body
1574 var requestBody struct {
1575 Path string `json:"path"`
1576 Content string `json:"content"`
1577 }
1578
1579 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001580 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001581 return
1582 }
1583 defer r.Body.Close()
1584
1585 // Check if path is provided
1586 if requestBody.Path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001587 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001588 return
1589 }
1590
1591 // Save file content using GitSaveFile
1592 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1593 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001594 httpError(w, r, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001595 return
1596 }
1597
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001598 // Auto-commit the changes
1599 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1600 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001601 httpError(w, r, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001602 return
1603 }
1604
1605 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001606 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001607 httpError(w, r, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001608 return
1609 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001610
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001611 // Return simple success response
1612 w.WriteHeader(http.StatusOK)
1613 w.Write([]byte("ok"))
1614}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001615
1616func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1617 if r.Method != "GET" {
1618 w.WriteHeader(http.StatusMethodNotAllowed)
1619 return
1620 }
1621
1622 repoDir := s.agent.RepoRoot()
1623 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1624 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001625 httpError(w, r, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001626 return
1627 }
1628
1629 w.Header().Set("Content-Type", "application/json")
1630 response := map[string][]string{
1631 "untracked_files": untrackedFiles,
1632 }
1633 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1634}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001635
1636// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1637func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1638 if r.Method != "GET" {
1639 w.WriteHeader(http.StatusMethodNotAllowed)
1640 return
1641 }
1642
1643 repoDir := s.agent.RepoRoot()
1644
1645 // Get the current HEAD commit hash and subject in one command
1646 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1647 cmd.Dir = repoDir
1648 output, err := cmd.Output()
1649 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001650 httpError(w, r, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001651 return
1652 }
1653
1654 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1655 if len(parts) != 2 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001656 httpError(w, r, "Unexpected git log output format", http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001657 return
1658 }
1659 hash := parts[0]
1660 subject := parts[1]
1661
1662 // Get list of remote names
1663 cmd = exec.Command("git", "remote")
1664 cmd.Dir = repoDir
1665 output, err = cmd.Output()
1666 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001667 httpError(w, r, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001668 return
1669 }
1670
1671 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1672
1673 remotes := make([]Remote, 0, len(remoteNames))
1674
1675 // Get URL and display name for each remote
1676 for _, remoteName := range remoteNames {
1677 cmd = exec.Command("git", "remote", "get-url", remoteName)
1678 cmd.Dir = repoDir
1679 urlOutput, err := cmd.Output()
1680 if err != nil {
1681 // Skip this remote if we can't get its URL
1682 continue
1683 }
1684 url := strings.TrimSpace(string(urlOutput))
1685
1686 // Set display name based on passthrough-upstream and remote name
1687 var displayName string
1688 var isGitHub bool
1689 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1690 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1691 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1692 isGitHub = false
1693 } else if remoteName == "origin" || remoteName == "upstream" {
1694 // Use git_origin value, simplified for GitHub URLs
1695 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1696 } else {
1697 // For other remotes, use the remote URL directly
1698 displayName, isGitHub = simplifyGitHubURL(url)
1699 }
1700
1701 remotes = append(remotes, Remote{
1702 Name: remoteName,
1703 URL: url,
1704 DisplayName: displayName,
1705 IsGitHub: isGitHub,
1706 })
1707 }
1708
1709 w.Header().Set("Content-Type", "application/json")
1710 response := GitPushInfoResponse{
1711 Hash: hash,
1712 Subject: subject,
1713 Remotes: remotes,
1714 }
1715 _ = json.NewEncoder(w).Encode(response)
1716}
1717
1718// handleGitPush handles git push operations
1719func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1720 if r.Method != "POST" {
1721 w.WriteHeader(http.StatusMethodNotAllowed)
1722 return
1723 }
1724
1725 // Parse request body
1726 var requestBody GitPushRequest
1727
1728 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001729 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001730 return
1731 }
1732 defer r.Body.Close()
1733
1734 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001735 httpError(w, r, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001736 return
1737 }
1738
1739 repoDir := s.agent.RepoRoot()
1740
1741 // Build the git push command
1742 args := []string{"push"}
1743 if requestBody.DryRun {
1744 args = append(args, "--dry-run")
1745 }
1746 if requestBody.Force {
1747 args = append(args, "--force")
1748 }
1749
1750 // Determine the target refspec
1751 var targetRef string
1752 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1753 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1754 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1755 } else {
1756 // Normal case: push to refs/heads/<branch>
1757 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1758 }
1759
1760 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1761
1762 // Log the git push command being executed
1763 slog.InfoContext(r.Context(), "executing git push command",
1764 "command", "git",
1765 "args", args,
1766 "remote", requestBody.Remote,
1767 "branch", requestBody.Branch,
1768 "commit", requestBody.Commit,
1769 "target_ref", targetRef,
1770 "dry_run", requestBody.DryRun,
1771 "force", requestBody.Force,
1772 "repo_dir", repoDir)
1773
1774 cmd := exec.Command("git", args...)
1775 cmd.Dir = repoDir
1776 // Ideally we want to pass an extra HTTP header so that the
1777 // server can know that this was likely a user initiated action
1778 // and not an agent-initiated action. However, git push weirdly
1779 // doesn't take a "-c" option, and the only handy env variable that
1780 // because a header is the user agent, so we abuse it...
1781 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1782 output, err := cmd.CombinedOutput()
1783
1784 // Log the result of the git push command
1785 if err != nil {
1786 slog.WarnContext(r.Context(), "git push command failed",
1787 "error", err,
1788 "output", string(output),
1789 "args", args)
1790 } else {
1791 slog.InfoContext(r.Context(), "git push command completed successfully",
1792 "output", string(output),
1793 "args", args)
1794 }
1795
1796 // Prepare response
1797 response := GitPushResponse{
1798 Success: err == nil,
1799 Output: string(output),
1800 DryRun: requestBody.DryRun,
1801 }
1802
1803 if err != nil {
1804 response.Error = err.Error()
1805 }
1806
1807 w.Header().Set("Content-Type", "application/json")
1808 _ = json.NewEncoder(w).Encode(response)
1809}