blob: 84e361555b474434e797cd8814764efe6366d636 [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"
philz83cf6062025-07-28 14:23:04 -070033 "sketch.dev/llm"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070034 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070035 "sketch.dev/loop"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070036 "sketch.dev/loop/server/gzhandler"
Earl Lee2e463fb2025-04-17 11:22:22 -070037)
38
Philip Zeyliger254c49f2025-07-17 17:26:24 -070039// Remote represents a git remote with display information.
40type Remote struct {
41 Name string `json:"name"`
42 URL string `json:"url"`
43 DisplayName string `json:"display_name"`
44 IsGitHub bool `json:"is_github"`
45}
46
47// GitPushInfoResponse represents the response from /git/pushinfo
48type GitPushInfoResponse struct {
49 Hash string `json:"hash"`
50 Subject string `json:"subject"`
51 Remotes []Remote `json:"remotes"`
52}
53
54// GitPushRequest represents the request body for /git/push
55type GitPushRequest struct {
56 Remote string `json:"remote"`
57 Branch string `json:"branch"`
58 Commit string `json:"commit"`
59 DryRun bool `json:"dry_run"`
60 Force bool `json:"force"`
61}
62
63// GitPushResponse represents the response from /git/push
64type GitPushResponse struct {
65 Success bool `json:"success"`
66 Output string `json:"output"`
67 DryRun bool `json:"dry_run"`
68 Error string `json:"error,omitempty"`
69}
70
Philip Zeyligere34ffd62025-07-25 13:20:49 -070071// httpError logs the error and sends an HTTP error response
72func httpError(w http.ResponseWriter, r *http.Request, message string, code int) {
73 slog.Error("HTTP error", "method", r.Method, "path", r.URL.Path, "message", message, "code", code)
74 http.Error(w, message, code)
75}
76
Philip Zeyliger254c49f2025-07-17 17:26:24 -070077// isGitHubURL checks if a URL is a GitHub URL
78func isGitHubURL(url string) bool {
79 return strings.Contains(url, "github.com")
80}
81
82// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
83// and also returns whether it's a github url
84func simplifyGitHubURL(url string) (string, bool) {
85 // Handle GitHub URLs in various formats
86 if strings.Contains(url, "github.com") {
87 // Extract owner/repo from URLs like:
88 // https://github.com/owner/repo.git
89 // git@github.com:owner/repo.git
90 // https://github.com/owner/repo
91 re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
92 if matches := re.FindStringSubmatch(url); len(matches) > 1 {
93 return matches[1], true
94 }
95 }
96 return url, false
97}
98
Earl Lee2e463fb2025-04-17 11:22:22 -070099// terminalSession represents a terminal session with its PTY and the event channel
100type terminalSession struct {
101 pty *os.File
102 eventsClients map[chan []byte]bool
103 lastEventClientID int
104 eventsClientsMutex sync.Mutex
105 cmd *exec.Cmd
106}
107
108// TerminalMessage represents a message sent from the client for terminal resize events
109type TerminalMessage struct {
110 Type string `json:"type"`
111 Cols uint16 `json:"cols"`
112 Rows uint16 `json:"rows"`
113}
114
115// TerminalResponse represents the response for a new terminal creation
116type TerminalResponse struct {
117 SessionID string `json:"sessionId"`
118}
119
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700120// TodoItem represents a single todo item for task management
121type TodoItem struct {
122 ID string `json:"id"`
123 Task string `json:"task"`
124 Status string `json:"status"` // queued, in-progress, completed
125}
126
127// TodoList represents a collection of todo items
128type TodoList struct {
129 Items []TodoItem `json:"items"`
130}
131
Sean McCulloughd9f13372025-04-21 15:08:49 -0700132type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700133 // null or 1: "old"
134 // 2: supports SSE for message updates
135 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700136 MessageCount int `json:"message_count"`
137 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
138 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700139 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700140 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000141 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700142 Hostname string `json:"hostname"` // deprecated
143 WorkingDir string `json:"working_dir"` // deprecated
144 OS string `json:"os"` // deprecated
145 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +0000146 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700147 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
148 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
149 SessionID string `json:"session_id"`
150 SSHAvailable bool `json:"ssh_available"`
151 SSHError string `json:"ssh_error,omitempty"`
152 InContainer bool `json:"in_container"`
153 FirstMessageIndex int `json:"first_message_index"`
154 AgentState string `json:"agent_state,omitempty"`
155 OutsideHostname string `json:"outside_hostname,omitempty"`
156 InsideHostname string `json:"inside_hostname,omitempty"`
157 OutsideOS string `json:"outside_os,omitempty"`
158 InsideOS string `json:"inside_os,omitempty"`
159 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
160 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700161 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
162 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
163 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
164 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700165 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
166 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000167 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
banksean5ab8fb82025-07-09 12:34:55 -0700168 TokenContextWindow int `json:"token_context_window,omitempty"`
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000169 Model string `json:"model,omitempty"` // Name of the model being used
bankseanc67d7bc2025-07-23 10:59:02 -0700170 SessionEnded bool `json:"session_ended,omitempty"`
171 CanSendMessages bool `json:"can_send_messages,omitempty"`
172 EndedAt time.Time `json:"ended_at,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000173}
174
175// Port represents an open TCP port
176type Port struct {
177 Proto string `json:"proto"` // "tcp" or "udp"
178 Port uint16 `json:"port"` // port number
179 Process string `json:"process"` // optional process name
180 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700181}
182
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700183type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700184 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
185 HostAddr string `json:"host_addr"`
186
187 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000188 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
189 SSHServerIdentity []byte `json:"ssh_server_identity"`
190 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
191 SSHHostCertificate []byte `json:"ssh_host_certificate"`
192 SSHAvailable bool `json:"ssh_available"`
193 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700194}
195
Earl Lee2e463fb2025-04-17 11:22:22 -0700196// Server serves sketch HTTP. Server implements http.Handler.
197type Server struct {
198 mux *http.ServeMux
199 agent loop.CodingAgent
200 hostname string
201 logFile *os.File
202 // Mutex to protect terminalSessions
203 ptyMutex sync.Mutex
204 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000205 sshAvailable bool
206 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700207}
208
209func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000210 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
211 if port := s.ParsePortProxyHost(r.Host); port != "" {
212 s.proxyToPort(w, r, port)
213 return
214 }
215
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 s.mux.ServeHTTP(w, r)
217}
218
Philip Zeyligera9710d72025-07-02 02:50:14 +0000219// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
220func (s *Server) ParsePortProxyHost(host string) string {
221 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
222 hostname := host
223 if idx := strings.LastIndex(host, ":"); idx > 0 {
224 hostname = host[:idx]
225 }
226
227 // Check if hostname matches p<port>.localhost pattern
228 if strings.HasSuffix(hostname, ".localhost") {
229 prefix := strings.TrimSuffix(hostname, ".localhost")
230 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
231 port := prefix[1:] // Remove 'p' prefix
232 // Basic validation - port should be numeric and in valid range
233 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
234 return port
235 }
236 }
237 }
238
239 return ""
240}
241
242// proxyToPort proxies the request to localhost:<port>
243func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
244 // Create a reverse proxy to localhost:<port>
245 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
246 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700247 httpError(w, r, "Failed to parse proxy target", http.StatusInternalServerError)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000248 return
249 }
250
251 proxy := httputil.NewSingleHostReverseProxy(target)
252
253 // Customize the Director to modify the request
254 originalDirector := proxy.Director
255 proxy.Director = func(req *http.Request) {
256 originalDirector(req)
257 // Set the target host
258 req.URL.Host = target.Host
259 req.URL.Scheme = target.Scheme
260 req.Host = target.Host
261 }
262
263 // Handle proxy errors
264 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
265 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700266 httpError(w, r, "Proxy error: "+err.Error(), http.StatusBadGateway)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000267 }
268
269 proxy.ServeHTTP(w, r)
270}
271
Earl Lee2e463fb2025-04-17 11:22:22 -0700272// New creates a new HTTP server.
273func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
274 s := &Server{
275 mux: http.NewServeMux(),
276 agent: agent,
277 hostname: getHostname(),
278 logFile: logFile,
279 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000280 sshAvailable: false,
281 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700282 }
283
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000284 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000285
286 // Git tool endpoints
287 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
288 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700289 s.mux.HandleFunc("/git/cat", s.handleGitCat)
290 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000291 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000292 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000293
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
295 // Check if a specific commit hash was requested
296 commit := r.URL.Query().Get("commit")
297
298 // Get the diff, optionally for a specific commit
299 var diff string
300 var err error
301 if commit != "" {
302 // Validate the commit hash format
303 if !isValidGitSHA(commit) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700304 httpError(w, r, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700305 return
306 }
307
308 diff, err = agent.Diff(&commit)
309 } else {
310 diff, err = agent.Diff(nil)
311 }
312
313 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700314 httpError(w, r, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700315 return
316 }
317
318 w.Header().Set("Content-Type", "text/plain")
319 w.Write([]byte(diff))
320 })
321
322 // Handler for initialization called by host sketch binary when inside docker.
323 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
324 defer func() {
325 if err := recover(); err != nil {
326 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
327
328 // Return an error response to the client
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700329 httpError(w, r, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 }
331 }()
332
333 if r.Method != "POST" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700334 httpError(w, r, "POST required", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700335 return
336 }
337
338 body, err := io.ReadAll(r.Body)
339 r.Body.Close()
340 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700341 httpError(w, r, "failed to read request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700342 return
343 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700344
345 m := &InitRequest{}
346 if err := json.Unmarshal(body, m); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700347 httpError(w, r, "bad request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700348 return
349 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700350
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000351 // Store SSH availability info
352 s.sshAvailable = m.SSHAvailable
353 s.sshError = m.SSHError
354
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700355 // Start the SSH server if the init request included ssh keys.
356 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
357 go func() {
358 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000359 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700360 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000361 // Update SSH error if server fails to start
362 s.sshAvailable = false
363 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700364 }
365 }()
366 }
367
Earl Lee2e463fb2025-04-17 11:22:22 -0700368 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700369 InDocker: true,
370 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700371 }
372 if err := agent.Init(ini); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700373 httpError(w, r, "init failed: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700374 return
375 }
376 w.Header().Set("Content-Type", "application/json")
377 io.WriteString(w, "{}\n")
378 })
379
380 // Handler for /messages?start=N&end=M (start/end are optional)
381 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
382 w.Header().Set("Content-Type", "application/json")
383
384 // Extract query parameters for range
385 var start, end int
386 var err error
387
388 currentCount := agent.MessageCount()
389
390 startParam := r.URL.Query().Get("start")
391 if startParam != "" {
392 start, err = strconv.Atoi(startParam)
393 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700394 httpError(w, r, "Invalid 'start' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700395 return
396 }
397 }
398
399 endParam := r.URL.Query().Get("end")
400 if endParam != "" {
401 end, err = strconv.Atoi(endParam)
402 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700403 httpError(w, r, "Invalid 'end' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700404 return
405 }
406 } else {
407 end = currentCount
408 }
409
410 if start < 0 || start > end || end > currentCount {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700411 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 -0700412 return
413 }
414
415 start = max(0, start)
416 end = min(agent.MessageCount(), end)
417 messages := agent.Messages(start, end)
418
419 // Create a JSON encoder with indentation for pretty-printing
420 encoder := json.NewEncoder(w)
421 encoder.SetIndent("", " ") // Two spaces for each indentation level
422
423 err = encoder.Encode(messages)
424 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700425 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700426 }
427 })
428
429 // Handler for /logs - displays the contents of the log file
430 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
431 if s.logFile == nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700432 httpError(w, r, "log file not set", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700433 return
434 }
435 logContents, err := os.ReadFile(s.logFile.Name())
436 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700437 httpError(w, r, "error reading log file: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700438 return
439 }
440 w.Header().Set("Content-Type", "text/html; charset=utf-8")
441 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
442 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
443 fmt.Fprintf(w, "</body>\n</html>")
444 })
445
446 // Handler for /download - downloads both messages and status as a JSON file
447 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
448 // Set headers for file download
449 w.Header().Set("Content-Type", "application/octet-stream")
450
451 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
452 timestamp := time.Now().Format("20060102-150405")
453 filename := fmt.Sprintf("sketch-%s.json", timestamp)
454
455 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
456
457 // Get all messages
458 messageCount := agent.MessageCount()
459 messages := agent.Messages(0, messageCount)
460
461 // Get status information (usage and other metadata)
462 totalUsage := agent.TotalUsage()
463 hostname := getHostname()
464 workingDir := getWorkingDir()
465
466 // Create a combined structure with all information
467 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700468 Messages []loop.AgentMessage `json:"messages"`
469 MessageCount int `json:"message_count"`
470 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
471 Hostname string `json:"hostname"`
472 WorkingDir string `json:"working_dir"`
473 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700474 }{
475 Messages: messages,
476 MessageCount: messageCount,
477 TotalUsage: totalUsage,
478 Hostname: hostname,
479 WorkingDir: workingDir,
480 DownloadTime: time.Now().Format(time.RFC3339),
481 }
482
483 // Marshal the JSON with indentation for better readability
484 jsonData, err := json.MarshalIndent(downloadData, "", " ")
485 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700486 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700487 return
488 }
489 w.Write(jsonData)
490 })
491
492 // The latter doesn't return until the number of messages has changed (from seen
493 // or from when this was called.)
494 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
495 pollParam := r.URL.Query().Get("poll")
496 seenParam := r.URL.Query().Get("seen")
497
498 // Get the client's current message count (if provided)
499 clientMessageCount := -1
500 var err error
501 if seenParam != "" {
502 clientMessageCount, err = strconv.Atoi(seenParam)
503 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700504 httpError(w, r, "Invalid 'seen' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700505 return
506 }
507 }
508
509 serverMessageCount := agent.MessageCount()
510
511 // Let lazy clients not have to specify this.
512 if clientMessageCount == -1 {
513 clientMessageCount = serverMessageCount
514 }
515
516 if pollParam == "true" {
517 ch := make(chan string)
518 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700519 it := agent.NewIterator(r.Context(), clientMessageCount)
520 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700521 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700522 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700523 }()
524 select {
525 case <-r.Context().Done():
526 slog.DebugContext(r.Context(), "abandoned poll request")
527 return
528 case <-time.After(90 * time.Second):
529 // Let the user call /state again to get the latest to limit how long our long polls hang out.
530 slog.DebugContext(r.Context(), "longish poll request")
531 break
532 case <-ch:
533 break
534 }
535 }
536
Earl Lee2e463fb2025-04-17 11:22:22 -0700537 w.Header().Set("Content-Type", "application/json")
538
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000539 // Use the shared getState function
540 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700541
542 // Create a JSON encoder with indentation for pretty-printing
543 encoder := json.NewEncoder(w)
544 encoder.SetIndent("", " ") // Two spaces for each indentation level
545
546 err = encoder.Encode(state)
547 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700548 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700549 }
550 })
551
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700552 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700553
554 // Terminal WebSocket handler
555 // Terminal endpoints - predefined terminals 1-9
556 // TODO: The UI doesn't actually know how to use terminals 2-9!
557 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
558 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700559 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700560 return
561 }
562 pathParts := strings.Split(r.URL.Path, "/")
563 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700564 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700565 return
566 }
567
568 sessionID := pathParts[3]
569 // Validate that the terminal ID is between 1-9
570 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700571 httpError(w, r, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700572 return
573 }
574
575 s.handleTerminalEvents(w, r, sessionID)
576 })
577
578 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
579 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700580 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700581 return
582 }
583 pathParts := strings.Split(r.URL.Path, "/")
584 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700585 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700586 return
587 }
588 sessionID := pathParts[3]
589 s.handleTerminalInput(w, r, sessionID)
590 })
591
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700592 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700593 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700594 webuiFS := embedded.WebUIFS()
595 appShell := "sketch-app-shell.html"
596 if r.URL.Query().Has("m") {
597 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700598 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700599 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700600 })
601
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700602 // Handler for /commit-description - returns the description of a git commit
603 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
604 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700605 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700606 return
607 }
608
609 // Get the revision parameter
610 revision := r.URL.Query().Get("revision")
611 if revision == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700612 httpError(w, r, "Missing revision parameter", http.StatusBadRequest)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700613 return
614 }
615
616 // Run git command to get commit description
617 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
618 // Use the working directory from the agent
619 cmd.Dir = s.agent.WorkingDir()
620
621 output, err := cmd.CombinedOutput()
622 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700623 httpError(w, r, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700624 return
625 }
626
627 // Prepare the response
628 resp := map[string]string{
629 "description": strings.TrimSpace(string(output)),
630 }
631
632 w.Header().Set("Content-Type", "application/json")
633 if err := json.NewEncoder(w).Encode(resp); err != nil {
634 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
635 }
636 })
637
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000638 // Handler for /screenshot/{id} - serves screenshot images
639 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
640 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700641 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000642 return
643 }
644
645 // Extract the screenshot ID from the path
646 pathParts := strings.Split(r.URL.Path, "/")
647 if len(pathParts) < 3 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700648 httpError(w, r, "Invalid screenshot ID", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000649 return
650 }
651
652 screenshotID := pathParts[2]
653
654 // Validate the ID format (prevent directory traversal)
655 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700656 httpError(w, r, "Invalid screenshot ID format", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000657 return
658 }
659
660 // Get the screenshot file path
661 filePath := browse.GetScreenshotPath(screenshotID)
662
663 // Check if the file exists
664 if _, err := os.Stat(filePath); os.IsNotExist(err) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700665 httpError(w, r, "Screenshot not found", http.StatusNotFound)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000666 return
667 }
668
669 // Serve the file
670 w.Header().Set("Content-Type", "image/png")
671 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
672 http.ServeFile(w, r, filePath)
673 })
674
Earl Lee2e463fb2025-04-17 11:22:22 -0700675 // Handler for POST /chat
676 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
677 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700678 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700679 return
680 }
681
682 // Parse the request body
683 var requestBody struct {
684 Message string `json:"message"`
685 }
686
687 decoder := json.NewDecoder(r.Body)
688 if err := decoder.Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700689 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700690 return
691 }
692 defer r.Body.Close()
693
694 if requestBody.Message == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700695 httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700696 return
697 }
698
699 agent.UserMessage(r.Context(), requestBody.Message)
700
701 w.WriteHeader(http.StatusOK)
702 })
703
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000704 // Handler for POST /upload - uploads a file to /tmp
705 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
706 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700707 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000708 return
709 }
710
711 // Limit to 10MB file size
712 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
713
714 // Parse the multipart form
715 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700716 httpError(w, r, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000717 return
718 }
719
720 // Get the file from the multipart form
721 file, handler, err := r.FormFile("file")
722 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700723 httpError(w, r, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000724 return
725 }
726 defer file.Close()
727
728 // Generate a unique ID (8 random bytes converted to 16 hex chars)
729 randBytes := make([]byte, 8)
730 if _, err := rand.Read(randBytes); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700731 httpError(w, r, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000732 return
733 }
734
735 // Get file extension from the original filename
736 ext := filepath.Ext(handler.Filename)
737
738 // Create a unique filename in the /tmp directory
739 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
740
741 // Create the destination file
742 destFile, err := os.Create(filename)
743 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700744 httpError(w, r, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000745 return
746 }
747 defer destFile.Close()
748
749 // Copy the file contents to the destination file
750 if _, err := io.Copy(destFile, file); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700751 httpError(w, r, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000752 return
753 }
754
755 // Return the path to the saved file
756 w.Header().Set("Content-Type", "application/json")
757 json.NewEncoder(w).Encode(map[string]string{"path": filename})
758 })
759
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700760 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
761 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
762
763 // Handler for /git/push - handles git push operations
764 s.mux.HandleFunc("/git/push", s.handleGitPush)
765
Earl Lee2e463fb2025-04-17 11:22:22 -0700766 // Handler for /cancel - cancels the current inner loop in progress
767 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
768 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700769 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 return
771 }
772
773 // Parse the request body (optional)
774 var requestBody struct {
775 Reason string `json:"reason"`
776 ToolCallID string `json:"tool_call_id"`
777 }
778
779 decoder := json.NewDecoder(r.Body)
780 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700781 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 return
783 }
784 defer r.Body.Close()
785
786 cancelReason := "user requested cancellation"
787 if requestBody.Reason != "" {
788 cancelReason = requestBody.Reason
789 }
790
791 if requestBody.ToolCallID != "" {
792 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
793 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700794 httpError(w, r, err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 return
796 }
797 // Return a success response
798 w.Header().Set("Content-Type", "application/json")
799 json.NewEncoder(w).Encode(map[string]string{
800 "status": "cancelled",
801 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700802 "reason": cancelReason,
803 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700804 return
805 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000806 // Call the CancelTurn method
807 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 // Return a success response
809 w.Header().Set("Content-Type", "application/json")
810 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
811 })
812
Pokey Rule397871d2025-05-19 15:02:45 +0100813 // Handler for /end - shuts down the inner sketch process
814 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
815 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700816 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Pokey Rule397871d2025-05-19 15:02:45 +0100817 return
818 }
819
820 // Parse the request body (optional)
821 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700822 Reason string `json:"reason"`
823 Happy *bool `json:"happy,omitempty"`
824 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100825 }
826
827 decoder := json.NewDecoder(r.Body)
828 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700829 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Pokey Rule397871d2025-05-19 15:02:45 +0100830 return
831 }
832 defer r.Body.Close()
833
834 endReason := "user requested end of session"
835 if requestBody.Reason != "" {
836 endReason = requestBody.Reason
837 }
838
839 // Send success response before exiting
840 w.Header().Set("Content-Type", "application/json")
841 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
842 if f, ok := w.(http.Flusher); ok {
843 f.Flush()
844 }
845
846 // Log that we're shutting down
847 slog.Info("Ending session", "reason", endReason)
848
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000849 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100850 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000851 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100852 os.Exit(0)
853 }()
854 })
855
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700856 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700857 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
858 debugMux.ServeHTTP(w, r)
859 })
860
861 return s, nil
862}
863
864// Utility functions
865func getHostname() string {
866 hostname, err := os.Hostname()
867 if err != nil {
868 return "unknown"
869 }
870 return hostname
871}
872
873func getWorkingDir() string {
874 wd, err := os.Getwd()
875 if err != nil {
876 return "unknown"
877 }
878 return wd
879}
880
881// createTerminalSession creates a new terminal session with the given ID
882func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
883 // Start a new shell process
884 shellPath := getShellPath()
885 cmd := exec.Command(shellPath)
886
887 // Get working directory from the agent if possible
888 workDir := getWorkingDir()
889 cmd.Dir = workDir
890
891 // Set up environment
892 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
893
894 // Start the command with a pty
895 ptmx, err := pty.Start(cmd)
896 if err != nil {
897 slog.Error("Failed to start pty", "error", err)
898 return nil, err
899 }
900
901 // Create the terminal session
902 session := &terminalSession{
903 pty: ptmx,
904 eventsClients: make(map[chan []byte]bool),
905 cmd: cmd,
906 }
907
908 // Start goroutine to read from pty and broadcast to all connected SSE clients
909 go s.readFromPtyAndBroadcast(sessionID, session)
910
911 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000912}
913
914// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700915func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
916 // Check if the session exists, if not, create it
917 s.ptyMutex.Lock()
918 session, exists := s.terminalSessions[sessionID]
919
920 if !exists {
921 // Create a new terminal session
922 var err error
923 session, err = s.createTerminalSession(sessionID)
924 if err != nil {
925 s.ptyMutex.Unlock()
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700926 httpError(w, r, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700927 return
928 }
929
930 // Store the new session
931 s.terminalSessions[sessionID] = session
932 }
933 s.ptyMutex.Unlock()
934
935 // Set headers for SSE
936 w.Header().Set("Content-Type", "text/event-stream")
937 w.Header().Set("Cache-Control", "no-cache")
938 w.Header().Set("Connection", "keep-alive")
939 w.Header().Set("Access-Control-Allow-Origin", "*")
940
941 // Create a channel for this client
942 events := make(chan []byte, 4096) // Buffer to prevent blocking
943
944 // Register this client's channel
945 session.eventsClientsMutex.Lock()
946 clientID := session.lastEventClientID + 1
947 session.lastEventClientID = clientID
948 session.eventsClients[events] = true
949 session.eventsClientsMutex.Unlock()
950
951 // When the client disconnects, remove their channel
952 defer func() {
953 session.eventsClientsMutex.Lock()
954 delete(session.eventsClients, events)
955 close(events)
956 session.eventsClientsMutex.Unlock()
957 }()
958
959 // Flush to send headers to client immediately
960 if f, ok := w.(http.Flusher); ok {
961 f.Flush()
962 }
963
964 // Send events to the client as they arrive
965 for {
966 select {
967 case <-r.Context().Done():
968 return
969 case data := <-events:
970 // Format as SSE with base64 encoding
971 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
972
973 // Flush the data immediately
974 if f, ok := w.(http.Flusher); ok {
975 f.Flush()
976 }
977 }
978 }
979}
980
981// handleTerminalInput processes input to the terminal
982func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
983 // Check if the session exists
984 s.ptyMutex.Lock()
985 session, exists := s.terminalSessions[sessionID]
986 s.ptyMutex.Unlock()
987
988 if !exists {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700989 httpError(w, r, "Terminal session not found", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700990 return
991 }
992
993 // Read the request body (terminal input or resize command)
994 body, err := io.ReadAll(r.Body)
995 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700996 httpError(w, r, "Failed to read request body", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700997 return
998 }
999
1000 // Check if it's a resize message
1001 if len(body) > 0 && body[0] == '{' {
1002 var msg TerminalMessage
1003 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
1004 if msg.Cols > 0 && msg.Rows > 0 {
1005 pty.Setsize(session.pty, &pty.Winsize{
1006 Cols: msg.Cols,
1007 Rows: msg.Rows,
1008 })
1009
1010 // Respond with success
1011 w.WriteHeader(http.StatusOK)
1012 return
1013 }
1014 }
1015 }
1016
1017 // Regular terminal input
1018 _, err = session.pty.Write(body)
1019 if err != nil {
1020 slog.Error("Failed to write to pty", "error", err)
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001021 httpError(w, r, "Failed to write to terminal", http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -07001022 return
1023 }
1024
1025 // Respond with success
1026 w.WriteHeader(http.StatusOK)
1027}
1028
1029// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1030func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1031 buf := make([]byte, 4096)
1032 defer func() {
1033 // Clean up when done
1034 s.ptyMutex.Lock()
1035 delete(s.terminalSessions, sessionID)
1036 s.ptyMutex.Unlock()
1037
1038 // Close the PTY
1039 session.pty.Close()
1040
1041 // Ensure process is terminated
1042 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 session.cmd.Process.Kill()
1044 }
David Crawshawb8431462025-07-09 13:10:32 +10001045 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001046
1047 // Close all client channels
1048 session.eventsClientsMutex.Lock()
1049 for ch := range session.eventsClients {
1050 delete(session.eventsClients, ch)
1051 close(ch)
1052 }
1053 session.eventsClientsMutex.Unlock()
1054 }()
1055
1056 for {
1057 n, err := session.pty.Read(buf)
1058 if err != nil {
1059 if err != io.EOF {
1060 slog.Error("Failed to read from pty", "error", err)
1061 }
1062 break
1063 }
1064
1065 // Make a copy of the data for each client
1066 data := make([]byte, n)
1067 copy(data, buf[:n])
1068
1069 // Broadcast to all connected clients
1070 session.eventsClientsMutex.Lock()
1071 for ch := range session.eventsClients {
1072 // Try to send, but don't block if channel is full
1073 select {
1074 case ch <- data:
1075 default:
1076 // Channel is full, drop the message for this client
1077 }
1078 }
1079 session.eventsClientsMutex.Unlock()
1080 }
1081}
1082
1083// getShellPath returns the path to the shell to use
1084func getShellPath() string {
1085 // Try to use the user's preferred shell
1086 shell := os.Getenv("SHELL")
1087 if shell != "" {
1088 return shell
1089 }
1090
1091 // Default to bash on Unix-like systems
1092 if _, err := os.Stat("/bin/bash"); err == nil {
1093 return "/bin/bash"
1094 }
1095
1096 // Fall back to sh
1097 return "/bin/sh"
1098}
1099
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001100func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001101 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001102 build := "unknown build"
1103 bi, ok := debug.ReadBuildInfo()
1104 if ok {
1105 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1106 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001107 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1108 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001109 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001110 fmt.Fprintf(w, `<!doctype html>
1111 <html><head><title>sketch debug</title></head><body>
1112 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001113 pid %d<br>
1114 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001116 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1117 <li><a href="pprof/profile">pprof/profile</a></li>
1118 <li><a href="pprof/symbol">pprof/symbol</a></li>
1119 <li><a href="pprof/trace">pprof/trace</a></li>
1120 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001121 <li><a href="conversation-history">conversation-history</a></li>
philz83cf6062025-07-28 14:23:04 -07001122 <li><a href="tools">tools</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001123 </ul>
1124 </body>
1125 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001126 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001127 })
1128 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1129 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1130 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1131 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1132 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001133
1134 // Add conversation history debug handler
1135 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1136 w.Header().Set("Content-Type", "application/json")
1137
1138 // Use type assertion to access the GetConvo method
1139 type ConvoProvider interface {
1140 GetConvo() loop.ConvoInterface
1141 }
1142
1143 if convoProvider, ok := agent.(ConvoProvider); ok {
1144 // Call the DebugJSON method to get the conversation history
1145 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1146 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001147 httpError(w, r, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001148 return
1149 }
1150
1151 // Write the JSON response
1152 w.Write(historyJSON)
1153 } else {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001154 httpError(w, r, "Agent does not support conversation history debugging", http.StatusNotImplemented)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001155 }
1156 })
1157
philz83cf6062025-07-28 14:23:04 -07001158 // Add tools debug handler
1159 mux.HandleFunc("GET /debug/tools", func(w http.ResponseWriter, r *http.Request) {
1160 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1161
1162 // Try to get the conversation and its tools
1163 type ConvoProvider interface {
1164 GetConvo() loop.ConvoInterface
1165 }
1166
1167 if convoProvider, ok := agent.(ConvoProvider); ok {
1168 convoInterface := convoProvider.GetConvo()
1169
1170 // Type assert to get the actual conversation
1171 if convo, ok := convoInterface.(*conversation.Convo); ok {
1172 // Render the tools debug page
1173 renderToolsDebugPage(w, convo.Tools)
1174 } else {
1175 http.Error(w, "Unable to access conversation tools", http.StatusInternalServerError)
1176 }
1177 } else {
1178 http.Error(w, "Agent does not support conversation debugging", http.StatusNotImplemented)
1179 }
1180 })
1181
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 return mux
1183}
1184
philz83cf6062025-07-28 14:23:04 -07001185// renderToolsDebugPage renders an HTML page showing all available tools
1186func renderToolsDebugPage(w http.ResponseWriter, tools []*llm.Tool) {
1187 fmt.Fprintf(w, `<!DOCTYPE html>
1188<html>
1189<head>
1190 <title>Sketch Tools Debug</title>
1191 <style>
1192 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 40px; }
1193 h1 { color: #333; }
1194 .tool { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 20px; margin: 20px 0; }
1195 .tool-name { font-size: 1.2em; font-weight: bold; color: #0366d6; margin-bottom: 8px; }
1196 .tool-description { color: #586069; margin-bottom: 12px; }
1197 .tool-schema { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-family: 'SF Mono', Monaco, monospace; font-size: 12px; overflow-x: auto; }
1198 .tool-meta { font-size: 0.9em; color: #656d76; margin-top: 8px; }
1199 .summary { background: #e6f3ff; border-left: 4px solid #0366d6; padding: 16px; margin-bottom: 30px; }
1200 </style>
1201</head>
1202<body>
1203 <h1>Sketch Tools Debug</h1>
1204 <div class="summary">
1205 <strong>Total Tools Available:</strong> %d
1206 </div>
1207`, len(tools))
1208
1209 for i, tool := range tools {
1210 fmt.Fprintf(w, ` <div class="tool">
1211 <div class="tool-name">%d. %s</div>
1212`, i+1, html.EscapeString(tool.Name))
1213
1214 if tool.Description != "" {
1215 fmt.Fprintf(w, ` <div class="tool-description">%s</div>
1216`, html.EscapeString(tool.Description))
1217 }
1218
1219 // Display schema
1220 if tool.InputSchema != nil {
1221 // Pretty print the JSON schema
1222 var schemaFormatted string
1223 if prettySchema, err := json.MarshalIndent(json.RawMessage(tool.InputSchema), "", " "); err == nil {
1224 schemaFormatted = string(prettySchema)
1225 } else {
1226 schemaFormatted = string(tool.InputSchema)
1227 }
1228 fmt.Fprintf(w, ` <div class="tool-schema">%s</div>
1229`, html.EscapeString(schemaFormatted))
1230 }
1231
1232 // Display metadata
1233 var metaParts []string
1234 if tool.Type != "" {
1235 metaParts = append(metaParts, fmt.Sprintf("Type: %s", tool.Type))
1236 }
1237 if tool.EndsTurn {
1238 metaParts = append(metaParts, "Ends Turn: true")
1239 }
1240 if len(metaParts) > 0 {
1241 fmt.Fprintf(w, ` <div class="tool-meta">%s</div>
1242`, html.EscapeString(strings.Join(metaParts, " | ")))
1243 }
1244
1245 fmt.Fprintf(w, ` </div>
1246`)
1247 }
1248
1249 fmt.Fprintf(w, `</body>
1250</html>`)
1251}
1252
Earl Lee2e463fb2025-04-17 11:22:22 -07001253// isValidGitSHA validates if a string looks like a valid git SHA hash.
1254// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1255func isValidGitSHA(sha string) bool {
1256 // Git SHA must be a hexadecimal string with at least 4 characters
1257 if len(sha) < 4 || len(sha) > 40 {
1258 return false
1259 }
1260
1261 // Check if the string only contains hexadecimal characters
1262 for _, char := range sha {
1263 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1264 return false
1265 }
1266 }
1267
1268 return true
1269}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001270
1271// /stream?from=N endpoint for Server-Sent Events
1272func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1273 w.Header().Set("Content-Type", "text/event-stream")
1274 w.Header().Set("Cache-Control", "no-cache")
1275 w.Header().Set("Connection", "keep-alive")
1276 w.Header().Set("Access-Control-Allow-Origin", "*")
1277
1278 // Extract the 'from' parameter
1279 fromParam := r.URL.Query().Get("from")
1280 var fromIndex int
1281 var err error
1282 if fromParam != "" {
1283 fromIndex, err = strconv.Atoi(fromParam)
1284 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001285 httpError(w, r, "Invalid 'from' parameter", http.StatusBadRequest)
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001286 return
1287 }
1288 }
1289
1290 // Ensure 'from' is valid
1291 currentCount := s.agent.MessageCount()
1292 if fromIndex < 0 {
1293 fromIndex = 0
1294 } else if fromIndex > currentCount {
1295 fromIndex = currentCount
1296 }
1297
1298 // Send the current state immediately
1299 state := s.getState()
1300
1301 // Create JSON encoder
1302 encoder := json.NewEncoder(w)
1303
1304 // Send state as an event
1305 fmt.Fprintf(w, "event: state\n")
1306 fmt.Fprintf(w, "data: ")
1307 encoder.Encode(state)
1308 fmt.Fprintf(w, "\n\n")
1309
1310 if f, ok := w.(http.Flusher); ok {
1311 f.Flush()
1312 }
1313
1314 // Create a context for the SSE stream
1315 ctx := r.Context()
1316
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001317 // Setup heartbeat timer
1318 heartbeatTicker := time.NewTicker(45 * time.Second)
1319 defer heartbeatTicker.Stop()
1320
1321 // Create a channel for messages
1322 messageChan := make(chan *loop.AgentMessage, 10)
1323
Philip Zeyligereab12de2025-05-14 02:35:53 +00001324 // Create a channel for state transitions
1325 stateChan := make(chan *loop.StateTransition, 10)
1326
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001327 // Start a goroutine to read messages without blocking the heartbeat
1328 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001329 // Create an iterator to receive new messages as they arrive
1330 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1331 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001332 defer close(messageChan)
1333 for {
1334 // This can block, but it's in its own goroutine
1335 newMessage := iterator.Next()
1336 if newMessage == nil {
1337 // No message available (likely due to context cancellation)
1338 slog.InfoContext(ctx, "No more messages available, ending message stream")
1339 return
1340 }
1341
1342 select {
1343 case messageChan <- newMessage:
1344 // Message sent to channel
1345 case <-ctx.Done():
1346 // Context cancelled
1347 return
1348 }
1349 }
1350 }()
1351
Philip Zeyligereab12de2025-05-14 02:35:53 +00001352 // Start a goroutine to read state transitions
1353 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001354 // Create an iterator to receive state transitions
1355 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1356 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001357 defer close(stateChan)
1358 for {
1359 // This can block, but it's in its own goroutine
1360 newTransition := stateIterator.Next()
1361 if newTransition == nil {
1362 // No transition available (likely due to context cancellation)
1363 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1364 return
1365 }
1366
1367 select {
1368 case stateChan <- newTransition:
1369 // Transition sent to channel
1370 case <-ctx.Done():
1371 // Context cancelled
1372 return
1373 }
1374 }
1375 }()
1376
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001377 // Stay connected and stream real-time updates
1378 for {
1379 select {
1380 case <-heartbeatTicker.C:
1381 // Send heartbeat event
1382 fmt.Fprintf(w, "event: heartbeat\n")
1383 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1384
1385 // Flush to send the heartbeat immediately
1386 if f, ok := w.(http.Flusher); ok {
1387 f.Flush()
1388 }
1389
1390 case <-ctx.Done():
1391 // Client disconnected
1392 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1393 return
1394
Philip Zeyligereab12de2025-05-14 02:35:53 +00001395 case _, ok := <-stateChan:
1396 if !ok {
1397 // Channel closed
1398 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1399 return
1400 }
1401
1402 // Get updated state
1403 state = s.getState()
1404
1405 // Send updated state after the state transition
1406 fmt.Fprintf(w, "event: state\n")
1407 fmt.Fprintf(w, "data: ")
1408 encoder.Encode(state)
1409 fmt.Fprintf(w, "\n\n")
1410
1411 // Flush to send the state immediately
1412 if f, ok := w.(http.Flusher); ok {
1413 f.Flush()
1414 }
1415
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001416 case newMessage, ok := <-messageChan:
1417 if !ok {
1418 // Channel closed
1419 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1420 return
1421 }
1422
1423 // Send the new message as an event
1424 fmt.Fprintf(w, "event: message\n")
1425 fmt.Fprintf(w, "data: ")
1426 encoder.Encode(newMessage)
1427 fmt.Fprintf(w, "\n\n")
1428
1429 // Get updated state
1430 state = s.getState()
1431
1432 // Send updated state after the message
1433 fmt.Fprintf(w, "event: state\n")
1434 fmt.Fprintf(w, "data: ")
1435 encoder.Encode(state)
1436 fmt.Fprintf(w, "\n\n")
1437
1438 // Flush to send the message and state immediately
1439 if f, ok := w.(http.Flusher); ok {
1440 f.Flush()
1441 }
1442 }
1443 }
1444}
1445
1446// Helper function to get the current state
1447func (s *Server) getState() State {
1448 serverMessageCount := s.agent.MessageCount()
1449 totalUsage := s.agent.TotalUsage()
1450
Philip Zeyliger64f60462025-06-16 13:57:10 -07001451 // Get diff stats
1452 diffAdded, diffRemoved := s.agent.DiffStats()
1453
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001454 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001455 StateVersion: 2,
1456 MessageCount: serverMessageCount,
1457 TotalUsage: &totalUsage,
1458 Hostname: s.hostname,
1459 WorkingDir: getWorkingDir(),
1460 // TODO: Rename this field to sketch-base?
1461 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001462 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001463 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001464 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001465 OS: s.agent.OS(),
1466 OutsideHostname: s.agent.OutsideHostname(),
1467 InsideHostname: s.hostname,
1468 OutsideOS: s.agent.OutsideOS(),
1469 InsideOS: s.agent.OS(),
1470 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1471 InsideWorkingDir: getWorkingDir(),
1472 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001473 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001474 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1475 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1476 SessionID: s.agent.SessionID(),
1477 SSHAvailable: s.sshAvailable,
1478 SSHError: s.sshError,
1479 InContainer: s.agent.IsInContainer(),
1480 FirstMessageIndex: s.agent.FirstMessageIndex(),
1481 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001482 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001483 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001484 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001485 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001486 DiffLinesAdded: diffAdded,
1487 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001488 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001489 TokenContextWindow: s.agent.TokenContextWindow(),
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001490 Model: s.agent.ModelName(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001491 }
1492}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001493
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001494// getOpenPorts retrieves the current open ports from the agent
1495func (s *Server) getOpenPorts() []Port {
1496 ports := s.agent.GetPorts()
1497 if ports == nil {
1498 return nil
1499 }
1500
1501 result := make([]Port, len(ports))
1502 for i, port := range ports {
1503 result[i] = Port{
1504 Proto: port.Proto,
1505 Port: port.Port,
1506 Process: port.Process,
1507 Pid: port.Pid,
1508 }
1509 }
1510 return result
1511}
1512
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001513func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1514 if r.Method != "GET" {
1515 w.WriteHeader(http.StatusMethodNotAllowed)
1516 return
1517 }
1518
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001519 // Get the git repository root directory from agent
1520 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001521
1522 // Parse query parameters
1523 query := r.URL.Query()
1524 commit := query.Get("commit")
1525 from := query.Get("from")
1526 to := query.Get("to")
1527
1528 // If commit is specified, use commit^ and commit as from and to
1529 if commit != "" {
1530 from = commit + "^"
1531 to = commit
1532 }
1533
1534 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001535 if from == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001536 httpError(w, r, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001537 return
1538 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001539 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001540
1541 // Call the git_tools function
1542 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1543 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001544 httpError(w, r, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001545 return
1546 }
1547
1548 // Return the result as JSON
1549 w.Header().Set("Content-Type", "application/json")
1550 if err := json.NewEncoder(w).Encode(diff); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001551 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001552 return
1553 }
1554}
1555
1556func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1557 if r.Method != "GET" {
1558 w.WriteHeader(http.StatusMethodNotAllowed)
1559 return
1560 }
1561
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001562 // Get the git repository root directory from agent
1563 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001564
1565 // Parse query parameters
1566 hash := r.URL.Query().Get("hash")
1567 if hash == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001568 httpError(w, r, "Missing required parameter: 'hash'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001569 return
1570 }
1571
1572 // Call the git_tools function
1573 show, err := git_tools.GitShow(repoDir, hash)
1574 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001575 httpError(w, r, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001576 return
1577 }
1578
1579 // Create a JSON response
1580 response := map[string]string{
1581 "hash": hash,
1582 "output": show,
1583 }
1584
1585 // Return the result as JSON
1586 w.Header().Set("Content-Type", "application/json")
1587 if err := json.NewEncoder(w).Encode(response); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001588 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001589 return
1590 }
1591}
1592
1593func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1594 if r.Method != "GET" {
1595 w.WriteHeader(http.StatusMethodNotAllowed)
1596 return
1597 }
1598
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001599 // Get the git repository root directory and initial commit from agent
1600 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001601 initialCommit := s.agent.SketchGitBaseRef()
1602
1603 // Call the git_tools function
1604 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1605 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001606 httpError(w, r, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001607 return
1608 }
1609
1610 // Return the result as JSON
1611 w.Header().Set("Content-Type", "application/json")
1612 if err := json.NewEncoder(w).Encode(log); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001613 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001614 return
1615 }
1616}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001617
1618func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1619 if r.Method != "GET" {
1620 w.WriteHeader(http.StatusMethodNotAllowed)
1621 return
1622 }
1623
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001624 // Get the git repository root directory from agent
1625 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001626
1627 // Parse query parameters
1628 query := r.URL.Query()
1629 path := query.Get("path")
1630
1631 // Check if path is provided
1632 if path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001633 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001634 return
1635 }
1636
1637 // Get file content using GitCat
1638 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001639 switch {
1640 case err == nil:
1641 // continued below
1642 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001643 w.WriteHeader(http.StatusNoContent)
1644 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001645 default:
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001646 httpError(w, r, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001647 return
1648 }
1649
1650 // Return the content as JSON for consistency with other endpoints
1651 w.Header().Set("Content-Type", "application/json")
1652 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001653 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001654 return
1655 }
1656}
1657
1658func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1659 if r.Method != "POST" {
1660 w.WriteHeader(http.StatusMethodNotAllowed)
1661 return
1662 }
1663
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001664 // Get the git repository root directory from agent
1665 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001666
1667 // Parse request body
1668 var requestBody struct {
1669 Path string `json:"path"`
1670 Content string `json:"content"`
1671 }
1672
1673 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001674 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001675 return
1676 }
1677 defer r.Body.Close()
1678
1679 // Check if path is provided
1680 if requestBody.Path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001681 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001682 return
1683 }
1684
1685 // Save file content using GitSaveFile
1686 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1687 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001688 httpError(w, r, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001689 return
1690 }
1691
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001692 // Auto-commit the changes
1693 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1694 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001695 httpError(w, r, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001696 return
1697 }
1698
1699 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001700 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001701 httpError(w, r, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001702 return
1703 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001704
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001705 // Return simple success response
1706 w.WriteHeader(http.StatusOK)
1707 w.Write([]byte("ok"))
1708}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001709
1710func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1711 if r.Method != "GET" {
1712 w.WriteHeader(http.StatusMethodNotAllowed)
1713 return
1714 }
1715
1716 repoDir := s.agent.RepoRoot()
1717 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1718 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001719 httpError(w, r, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001720 return
1721 }
1722
1723 w.Header().Set("Content-Type", "application/json")
1724 response := map[string][]string{
1725 "untracked_files": untrackedFiles,
1726 }
1727 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1728}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001729
1730// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1731func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1732 if r.Method != "GET" {
1733 w.WriteHeader(http.StatusMethodNotAllowed)
1734 return
1735 }
1736
1737 repoDir := s.agent.RepoRoot()
1738
1739 // Get the current HEAD commit hash and subject in one command
1740 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1741 cmd.Dir = repoDir
1742 output, err := cmd.Output()
1743 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001744 httpError(w, r, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001745 return
1746 }
1747
1748 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1749 if len(parts) != 2 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001750 httpError(w, r, "Unexpected git log output format", http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001751 return
1752 }
1753 hash := parts[0]
1754 subject := parts[1]
1755
1756 // Get list of remote names
1757 cmd = exec.Command("git", "remote")
1758 cmd.Dir = repoDir
1759 output, err = cmd.Output()
1760 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001761 httpError(w, r, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001762 return
1763 }
1764
1765 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1766
1767 remotes := make([]Remote, 0, len(remoteNames))
1768
1769 // Get URL and display name for each remote
1770 for _, remoteName := range remoteNames {
1771 cmd = exec.Command("git", "remote", "get-url", remoteName)
1772 cmd.Dir = repoDir
1773 urlOutput, err := cmd.Output()
1774 if err != nil {
1775 // Skip this remote if we can't get its URL
1776 continue
1777 }
1778 url := strings.TrimSpace(string(urlOutput))
1779
1780 // Set display name based on passthrough-upstream and remote name
1781 var displayName string
1782 var isGitHub bool
1783 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1784 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1785 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1786 isGitHub = false
1787 } else if remoteName == "origin" || remoteName == "upstream" {
1788 // Use git_origin value, simplified for GitHub URLs
1789 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1790 } else {
1791 // For other remotes, use the remote URL directly
1792 displayName, isGitHub = simplifyGitHubURL(url)
1793 }
1794
1795 remotes = append(remotes, Remote{
1796 Name: remoteName,
1797 URL: url,
1798 DisplayName: displayName,
1799 IsGitHub: isGitHub,
1800 })
1801 }
1802
1803 w.Header().Set("Content-Type", "application/json")
1804 response := GitPushInfoResponse{
1805 Hash: hash,
1806 Subject: subject,
1807 Remotes: remotes,
1808 }
1809 _ = json.NewEncoder(w).Encode(response)
1810}
1811
1812// handleGitPush handles git push operations
1813func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1814 if r.Method != "POST" {
1815 w.WriteHeader(http.StatusMethodNotAllowed)
1816 return
1817 }
1818
1819 // Parse request body
1820 var requestBody GitPushRequest
1821
1822 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001823 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001824 return
1825 }
1826 defer r.Body.Close()
1827
1828 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001829 httpError(w, r, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001830 return
1831 }
1832
1833 repoDir := s.agent.RepoRoot()
1834
1835 // Build the git push command
1836 args := []string{"push"}
1837 if requestBody.DryRun {
1838 args = append(args, "--dry-run")
1839 }
1840 if requestBody.Force {
1841 args = append(args, "--force")
1842 }
1843
1844 // Determine the target refspec
1845 var targetRef string
1846 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1847 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1848 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1849 } else {
1850 // Normal case: push to refs/heads/<branch>
1851 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1852 }
1853
1854 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1855
1856 // Log the git push command being executed
1857 slog.InfoContext(r.Context(), "executing git push command",
1858 "command", "git",
1859 "args", args,
1860 "remote", requestBody.Remote,
1861 "branch", requestBody.Branch,
1862 "commit", requestBody.Commit,
1863 "target_ref", targetRef,
1864 "dry_run", requestBody.DryRun,
1865 "force", requestBody.Force,
1866 "repo_dir", repoDir)
1867
1868 cmd := exec.Command("git", args...)
1869 cmd.Dir = repoDir
1870 // Ideally we want to pass an extra HTTP header so that the
1871 // server can know that this was likely a user initiated action
1872 // and not an agent-initiated action. However, git push weirdly
1873 // doesn't take a "-c" option, and the only handy env variable that
1874 // because a header is the user agent, so we abuse it...
1875 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1876 output, err := cmd.CombinedOutput()
1877
1878 // Log the result of the git push command
1879 if err != nil {
1880 slog.WarnContext(r.Context(), "git push command failed",
1881 "error", err,
1882 "output", string(output),
1883 "args", args)
1884 } else {
1885 slog.InfoContext(r.Context(), "git push command completed successfully",
1886 "output", string(output),
1887 "args", args)
1888 }
1889
1890 // Prepare response
1891 response := GitPushResponse{
1892 Success: err == nil,
1893 Output: string(output),
1894 DryRun: requestBody.DryRun,
1895 }
1896
1897 if err != nil {
1898 response.Error = err.Error()
1899 }
1900
1901 w.Header().Set("Content-Type", "application/json")
1902 _ = json.NewEncoder(w).Encode(response)
1903}