blob: dacc4f614beb97ba77cc2f569983e75c275b7f87 [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"
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00007 "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07008 "encoding/base64"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00009 "encoding/hex"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "encoding/json"
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +000011 "errors"
Earl Lee2e463fb2025-04-17 11:22:22 -070012 "fmt"
13 "html"
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +000014 "html/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "log/slog"
17 "net/http"
Philip Zeyligera9710d72025-07-02 02:50:14 +000018 "net/http/httputil"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "net/http/pprof"
Philip Zeyligera9710d72025-07-02 02:50:14 +000020 "net/url"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "os"
22 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000023 "path/filepath"
Philip Zeyliger254c49f2025-07-17 17:26:24 -070024 "regexp"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070025 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070026 "strconv"
27 "strings"
28 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070029 "time"
30
31 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000032 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070033 "sketch.dev/embedded"
34 "sketch.dev/git_tools"
philz83cf6062025-07-28 14:23:04 -070035 "sketch.dev/llm"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070036 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070037 "sketch.dev/loop"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070038 "sketch.dev/loop/server/gzhandler"
Earl Lee2e463fb2025-04-17 11:22:22 -070039)
40
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +000041//go:embed templates/*
42var templateFS embed.FS
43
Philip Zeyliger254c49f2025-07-17 17:26:24 -070044// Remote represents a git remote with display information.
45type Remote struct {
46 Name string `json:"name"`
47 URL string `json:"url"`
48 DisplayName string `json:"display_name"`
49 IsGitHub bool `json:"is_github"`
50}
51
52// GitPushInfoResponse represents the response from /git/pushinfo
53type GitPushInfoResponse struct {
54 Hash string `json:"hash"`
55 Subject string `json:"subject"`
56 Remotes []Remote `json:"remotes"`
57}
58
59// GitPushRequest represents the request body for /git/push
60type GitPushRequest struct {
61 Remote string `json:"remote"`
62 Branch string `json:"branch"`
63 Commit string `json:"commit"`
64 DryRun bool `json:"dry_run"`
65 Force bool `json:"force"`
66}
67
68// GitPushResponse represents the response from /git/push
69type GitPushResponse struct {
70 Success bool `json:"success"`
71 Output string `json:"output"`
72 DryRun bool `json:"dry_run"`
73 Error string `json:"error,omitempty"`
74}
75
Philip Zeyligere34ffd62025-07-25 13:20:49 -070076// httpError logs the error and sends an HTTP error response
77func httpError(w http.ResponseWriter, r *http.Request, message string, code int) {
78 slog.Error("HTTP error", "method", r.Method, "path", r.URL.Path, "message", message, "code", code)
79 http.Error(w, message, code)
80}
81
Philip Zeyliger254c49f2025-07-17 17:26:24 -070082// isGitHubURL checks if a URL is a GitHub URL
83func isGitHubURL(url string) bool {
84 return strings.Contains(url, "github.com")
85}
86
87// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
88// and also returns whether it's a github url
89func simplifyGitHubURL(url string) (string, bool) {
90 // Handle GitHub URLs in various formats
91 if strings.Contains(url, "github.com") {
92 // Extract owner/repo from URLs like:
93 // https://github.com/owner/repo.git
94 // git@github.com:owner/repo.git
95 // https://github.com/owner/repo
96 re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
97 if matches := re.FindStringSubmatch(url); len(matches) > 1 {
98 return matches[1], true
99 }
100 }
101 return url, false
102}
103
Earl Lee2e463fb2025-04-17 11:22:22 -0700104// terminalSession represents a terminal session with its PTY and the event channel
105type terminalSession struct {
106 pty *os.File
107 eventsClients map[chan []byte]bool
108 lastEventClientID int
109 eventsClientsMutex sync.Mutex
110 cmd *exec.Cmd
111}
112
113// TerminalMessage represents a message sent from the client for terminal resize events
114type TerminalMessage struct {
115 Type string `json:"type"`
116 Cols uint16 `json:"cols"`
117 Rows uint16 `json:"rows"`
118}
119
120// TerminalResponse represents the response for a new terminal creation
121type TerminalResponse struct {
122 SessionID string `json:"sessionId"`
123}
124
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700125// TodoItem represents a single todo item for task management
126type TodoItem struct {
127 ID string `json:"id"`
128 Task string `json:"task"`
129 Status string `json:"status"` // queued, in-progress, completed
130}
131
132// TodoList represents a collection of todo items
133type TodoList struct {
134 Items []TodoItem `json:"items"`
135}
136
Sean McCulloughd9f13372025-04-21 15:08:49 -0700137type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700138 // null or 1: "old"
139 // 2: supports SSE for message updates
140 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700141 MessageCount int `json:"message_count"`
142 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
143 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700144 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700145 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000146 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700147 Hostname string `json:"hostname"` // deprecated
148 WorkingDir string `json:"working_dir"` // deprecated
149 OS string `json:"os"` // deprecated
150 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +0000151 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700152 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
153 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
154 SessionID string `json:"session_id"`
155 SSHAvailable bool `json:"ssh_available"`
156 SSHError string `json:"ssh_error,omitempty"`
157 InContainer bool `json:"in_container"`
158 FirstMessageIndex int `json:"first_message_index"`
159 AgentState string `json:"agent_state,omitempty"`
160 OutsideHostname string `json:"outside_hostname,omitempty"`
161 InsideHostname string `json:"inside_hostname,omitempty"`
162 OutsideOS string `json:"outside_os,omitempty"`
163 InsideOS string `json:"inside_os,omitempty"`
164 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
165 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700166 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
167 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
168 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
169 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700170 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
171 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000172 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
banksean5ab8fb82025-07-09 12:34:55 -0700173 TokenContextWindow int `json:"token_context_window,omitempty"`
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000174 Model string `json:"model,omitempty"` // Name of the model being used
bankseanc67d7bc2025-07-23 10:59:02 -0700175 SessionEnded bool `json:"session_ended,omitempty"`
176 CanSendMessages bool `json:"can_send_messages,omitempty"`
177 EndedAt time.Time `json:"ended_at,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000178}
179
180// Port represents an open TCP port
181type Port struct {
182 Proto string `json:"proto"` // "tcp" or "udp"
183 Port uint16 `json:"port"` // port number
184 Process string `json:"process"` // optional process name
185 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700186}
187
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700188type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700189 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
190 HostAddr string `json:"host_addr"`
191
192 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000193 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
194 SSHServerIdentity []byte `json:"ssh_server_identity"`
195 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
196 SSHHostCertificate []byte `json:"ssh_host_certificate"`
197 SSHAvailable bool `json:"ssh_available"`
198 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700199}
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201// Server serves sketch HTTP. Server implements http.Handler.
202type Server struct {
203 mux *http.ServeMux
204 agent loop.CodingAgent
205 hostname string
206 logFile *os.File
207 // Mutex to protect terminalSessions
208 ptyMutex sync.Mutex
209 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000210 sshAvailable bool
211 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700212}
213
214func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000215 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
216 if port := s.ParsePortProxyHost(r.Host); port != "" {
217 s.proxyToPort(w, r, port)
218 return
219 }
220
Earl Lee2e463fb2025-04-17 11:22:22 -0700221 s.mux.ServeHTTP(w, r)
222}
223
Philip Zeyligera9710d72025-07-02 02:50:14 +0000224// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
225func (s *Server) ParsePortProxyHost(host string) string {
226 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
227 hostname := host
228 if idx := strings.LastIndex(host, ":"); idx > 0 {
229 hostname = host[:idx]
230 }
231
232 // Check if hostname matches p<port>.localhost pattern
233 if strings.HasSuffix(hostname, ".localhost") {
234 prefix := strings.TrimSuffix(hostname, ".localhost")
235 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
236 port := prefix[1:] // Remove 'p' prefix
237 // Basic validation - port should be numeric and in valid range
238 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
239 return port
240 }
241 }
242 }
243
244 return ""
245}
246
247// proxyToPort proxies the request to localhost:<port>
248func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
249 // Create a reverse proxy to localhost:<port>
250 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
251 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700252 httpError(w, r, "Failed to parse proxy target", http.StatusInternalServerError)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000253 return
254 }
255
256 proxy := httputil.NewSingleHostReverseProxy(target)
257
258 // Customize the Director to modify the request
259 originalDirector := proxy.Director
260 proxy.Director = func(req *http.Request) {
261 originalDirector(req)
262 // Set the target host
263 req.URL.Host = target.Host
264 req.URL.Scheme = target.Scheme
265 req.Host = target.Host
266 }
267
268 // Handle proxy errors
269 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
270 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700271 httpError(w, r, "Proxy error: "+err.Error(), http.StatusBadGateway)
Philip Zeyligera9710d72025-07-02 02:50:14 +0000272 }
273
274 proxy.ServeHTTP(w, r)
275}
276
Earl Lee2e463fb2025-04-17 11:22:22 -0700277// New creates a new HTTP server.
278func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
279 s := &Server{
280 mux: http.NewServeMux(),
281 agent: agent,
282 hostname: getHostname(),
283 logFile: logFile,
284 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000285 sshAvailable: false,
286 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 }
288
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000289 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000290
291 // Git tool endpoints
292 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
293 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700294 s.mux.HandleFunc("/git/cat", s.handleGitCat)
295 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000296 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000297 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000298
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
300 // Check if a specific commit hash was requested
301 commit := r.URL.Query().Get("commit")
302
303 // Get the diff, optionally for a specific commit
304 var diff string
305 var err error
306 if commit != "" {
307 // Validate the commit hash format
308 if !isValidGitSHA(commit) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700309 httpError(w, r, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 return
311 }
312
313 diff, err = agent.Diff(&commit)
314 } else {
315 diff, err = agent.Diff(nil)
316 }
317
318 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700319 httpError(w, r, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700320 return
321 }
322
323 w.Header().Set("Content-Type", "text/plain")
324 w.Write([]byte(diff))
325 })
326
327 // Handler for initialization called by host sketch binary when inside docker.
328 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
329 defer func() {
330 if err := recover(); err != nil {
331 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
332
333 // Return an error response to the client
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700334 httpError(w, r, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700335 }
336 }()
337
338 if r.Method != "POST" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700339 httpError(w, r, "POST required", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700340 return
341 }
342
343 body, err := io.ReadAll(r.Body)
344 r.Body.Close()
345 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700346 httpError(w, r, "failed to read request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700347 return
348 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700349
350 m := &InitRequest{}
351 if err := json.Unmarshal(body, m); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700352 httpError(w, r, "bad request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700353 return
354 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700355
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000356 // Store SSH availability info
357 s.sshAvailable = m.SSHAvailable
358 s.sshError = m.SSHError
359
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700360 // Start the SSH server if the init request included ssh keys.
361 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
362 go func() {
363 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000364 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700365 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000366 // Update SSH error if server fails to start
367 s.sshAvailable = false
368 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700369 }
370 }()
371 }
372
Earl Lee2e463fb2025-04-17 11:22:22 -0700373 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700374 InDocker: true,
375 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700376 }
377 if err := agent.Init(ini); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700378 httpError(w, r, "init failed: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700379 return
380 }
381 w.Header().Set("Content-Type", "application/json")
382 io.WriteString(w, "{}\n")
383 })
384
385 // Handler for /messages?start=N&end=M (start/end are optional)
386 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
387 w.Header().Set("Content-Type", "application/json")
388
389 // Extract query parameters for range
390 var start, end int
391 var err error
392
393 currentCount := agent.MessageCount()
394
395 startParam := r.URL.Query().Get("start")
396 if startParam != "" {
397 start, err = strconv.Atoi(startParam)
398 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700399 httpError(w, r, "Invalid 'start' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700400 return
401 }
402 }
403
404 endParam := r.URL.Query().Get("end")
405 if endParam != "" {
406 end, err = strconv.Atoi(endParam)
407 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700408 httpError(w, r, "Invalid 'end' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700409 return
410 }
411 } else {
412 end = currentCount
413 }
414
415 if start < 0 || start > end || end > currentCount {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700416 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 -0700417 return
418 }
419
420 start = max(0, start)
421 end = min(agent.MessageCount(), end)
422 messages := agent.Messages(start, end)
423
424 // Create a JSON encoder with indentation for pretty-printing
425 encoder := json.NewEncoder(w)
426 encoder.SetIndent("", " ") // Two spaces for each indentation level
427
428 err = encoder.Encode(messages)
429 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700430 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 }
432 })
433
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +0000434 // Handler for /debug/logs - displays the contents of the log file
435 s.mux.HandleFunc("/debug/logs", func(w http.ResponseWriter, r *http.Request) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700436 if s.logFile == nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700437 httpError(w, r, "log file not set", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700438 return
439 }
440 logContents, err := os.ReadFile(s.logFile.Name())
441 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700442 httpError(w, r, "error reading log file: "+err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700443 return
444 }
445 w.Header().Set("Content-Type", "text/html; charset=utf-8")
446 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
447 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
448 fmt.Fprintf(w, "</body>\n</html>")
449 })
450
451 // Handler for /download - downloads both messages and status as a JSON file
452 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
453 // Set headers for file download
454 w.Header().Set("Content-Type", "application/octet-stream")
455
456 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
457 timestamp := time.Now().Format("20060102-150405")
458 filename := fmt.Sprintf("sketch-%s.json", timestamp)
459
460 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
461
462 // Get all messages
463 messageCount := agent.MessageCount()
464 messages := agent.Messages(0, messageCount)
465
466 // Get status information (usage and other metadata)
467 totalUsage := agent.TotalUsage()
468 hostname := getHostname()
469 workingDir := getWorkingDir()
470
471 // Create a combined structure with all information
472 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700473 Messages []loop.AgentMessage `json:"messages"`
474 MessageCount int `json:"message_count"`
475 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
476 Hostname string `json:"hostname"`
477 WorkingDir string `json:"working_dir"`
478 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700479 }{
480 Messages: messages,
481 MessageCount: messageCount,
482 TotalUsage: totalUsage,
483 Hostname: hostname,
484 WorkingDir: workingDir,
485 DownloadTime: time.Now().Format(time.RFC3339),
486 }
487
488 // Marshal the JSON with indentation for better readability
489 jsonData, err := json.MarshalIndent(downloadData, "", " ")
490 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700491 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700492 return
493 }
494 w.Write(jsonData)
495 })
496
497 // The latter doesn't return until the number of messages has changed (from seen
498 // or from when this was called.)
499 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
500 pollParam := r.URL.Query().Get("poll")
501 seenParam := r.URL.Query().Get("seen")
502
503 // Get the client's current message count (if provided)
504 clientMessageCount := -1
505 var err error
506 if seenParam != "" {
507 clientMessageCount, err = strconv.Atoi(seenParam)
508 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700509 httpError(w, r, "Invalid 'seen' parameter", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700510 return
511 }
512 }
513
514 serverMessageCount := agent.MessageCount()
515
516 // Let lazy clients not have to specify this.
517 if clientMessageCount == -1 {
518 clientMessageCount = serverMessageCount
519 }
520
521 if pollParam == "true" {
522 ch := make(chan string)
523 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700524 it := agent.NewIterator(r.Context(), clientMessageCount)
525 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700526 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700527 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700528 }()
529 select {
530 case <-r.Context().Done():
531 slog.DebugContext(r.Context(), "abandoned poll request")
532 return
533 case <-time.After(90 * time.Second):
534 // Let the user call /state again to get the latest to limit how long our long polls hang out.
535 slog.DebugContext(r.Context(), "longish poll request")
536 break
537 case <-ch:
538 break
539 }
540 }
541
Earl Lee2e463fb2025-04-17 11:22:22 -0700542 w.Header().Set("Content-Type", "application/json")
543
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000544 // Use the shared getState function
545 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700546
547 // Create a JSON encoder with indentation for pretty-printing
548 encoder := json.NewEncoder(w)
549 encoder.SetIndent("", " ") // Two spaces for each indentation level
550
551 err = encoder.Encode(state)
552 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700553 httpError(w, r, err.Error(), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700554 }
555 })
556
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700557 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700558
559 // Terminal WebSocket handler
560 // Terminal endpoints - predefined terminals 1-9
561 // TODO: The UI doesn't actually know how to use terminals 2-9!
562 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
563 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700564 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700565 return
566 }
567 pathParts := strings.Split(r.URL.Path, "/")
568 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700569 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700570 return
571 }
572
573 sessionID := pathParts[3]
574 // Validate that the terminal ID is between 1-9
575 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700576 httpError(w, r, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700577 return
578 }
579
580 s.handleTerminalEvents(w, r, sessionID)
581 })
582
583 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
584 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700585 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700586 return
587 }
588 pathParts := strings.Split(r.URL.Path, "/")
589 if len(pathParts) < 4 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700590 httpError(w, r, "Invalid terminal ID", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700591 return
592 }
593 sessionID := pathParts[3]
594 s.handleTerminalInput(w, r, sessionID)
595 })
596
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700597 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700598 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700599 webuiFS := embedded.WebUIFS()
600 appShell := "sketch-app-shell.html"
601 if r.URL.Query().Has("m") {
602 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700603 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700604 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700605 })
606
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700607 // Handler for /commit-description - returns the description of a git commit
608 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
609 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700610 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700611 return
612 }
613
614 // Get the revision parameter
615 revision := r.URL.Query().Get("revision")
616 if revision == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700617 httpError(w, r, "Missing revision parameter", http.StatusBadRequest)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700618 return
619 }
620
621 // Run git command to get commit description
622 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
623 // Use the working directory from the agent
624 cmd.Dir = s.agent.WorkingDir()
625
626 output, err := cmd.CombinedOutput()
627 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700628 httpError(w, r, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700629 return
630 }
631
632 // Prepare the response
633 resp := map[string]string{
634 "description": strings.TrimSpace(string(output)),
635 }
636
637 w.Header().Set("Content-Type", "application/json")
638 if err := json.NewEncoder(w).Encode(resp); err != nil {
639 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
640 }
641 })
642
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000643 // Handler for /screenshot/{id} - serves screenshot images
644 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
645 if r.Method != http.MethodGet {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700646 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000647 return
648 }
649
650 // Extract the screenshot ID from the path
651 pathParts := strings.Split(r.URL.Path, "/")
652 if len(pathParts) < 3 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700653 httpError(w, r, "Invalid screenshot ID", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000654 return
655 }
656
657 screenshotID := pathParts[2]
658
659 // Validate the ID format (prevent directory traversal)
660 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700661 httpError(w, r, "Invalid screenshot ID format", http.StatusBadRequest)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000662 return
663 }
664
665 // Get the screenshot file path
666 filePath := browse.GetScreenshotPath(screenshotID)
667
668 // Check if the file exists
669 if _, err := os.Stat(filePath); os.IsNotExist(err) {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700670 httpError(w, r, "Screenshot not found", http.StatusNotFound)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000671 return
672 }
673
674 // Serve the file
675 w.Header().Set("Content-Type", "image/png")
676 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
677 http.ServeFile(w, r, filePath)
678 })
679
Earl Lee2e463fb2025-04-17 11:22:22 -0700680 // Handler for POST /chat
681 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
682 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700683 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 return
685 }
686
687 // Parse the request body
688 var requestBody struct {
689 Message string `json:"message"`
690 }
691
692 decoder := json.NewDecoder(r.Body)
693 if err := decoder.Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700694 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700695 return
696 }
697 defer r.Body.Close()
698
699 if requestBody.Message == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700700 httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700701 return
702 }
703
704 agent.UserMessage(r.Context(), requestBody.Message)
705
706 w.WriteHeader(http.StatusOK)
707 })
708
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000709 // Handler for POST /upload - uploads a file to /tmp
710 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
711 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700712 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000713 return
714 }
715
716 // Limit to 10MB file size
717 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
718
719 // Parse the multipart form
720 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700721 httpError(w, r, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000722 return
723 }
724
725 // Get the file from the multipart form
726 file, handler, err := r.FormFile("file")
727 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700728 httpError(w, r, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000729 return
730 }
731 defer file.Close()
732
733 // Generate a unique ID (8 random bytes converted to 16 hex chars)
734 randBytes := make([]byte, 8)
735 if _, err := rand.Read(randBytes); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700736 httpError(w, r, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000737 return
738 }
739
740 // Get file extension from the original filename
741 ext := filepath.Ext(handler.Filename)
742
743 // Create a unique filename in the /tmp directory
744 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
745
746 // Create the destination file
747 destFile, err := os.Create(filename)
748 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700749 httpError(w, r, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000750 return
751 }
752 defer destFile.Close()
753
754 // Copy the file contents to the destination file
755 if _, err := io.Copy(destFile, file); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700756 httpError(w, r, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000757 return
758 }
759
760 // Return the path to the saved file
761 w.Header().Set("Content-Type", "application/json")
762 json.NewEncoder(w).Encode(map[string]string{"path": filename})
763 })
764
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700765 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
766 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
767
768 // Handler for /git/push - handles git push operations
769 s.mux.HandleFunc("/git/push", s.handleGitPush)
770
Earl Lee2e463fb2025-04-17 11:22:22 -0700771 // Handler for /cancel - cancels the current inner loop in progress
772 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
773 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700774 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700775 return
776 }
777
778 // Parse the request body (optional)
779 var requestBody struct {
780 Reason string `json:"reason"`
781 ToolCallID string `json:"tool_call_id"`
782 }
783
784 decoder := json.NewDecoder(r.Body)
785 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700786 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700787 return
788 }
789 defer r.Body.Close()
790
791 cancelReason := "user requested cancellation"
792 if requestBody.Reason != "" {
793 cancelReason = requestBody.Reason
794 }
795
796 if requestBody.ToolCallID != "" {
797 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
798 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700799 httpError(w, r, err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700800 return
801 }
802 // Return a success response
803 w.Header().Set("Content-Type", "application/json")
804 json.NewEncoder(w).Encode(map[string]string{
805 "status": "cancelled",
806 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700807 "reason": cancelReason,
808 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700809 return
810 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000811 // Call the CancelTurn method
812 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 // Return a success response
814 w.Header().Set("Content-Type", "application/json")
815 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
816 })
817
Pokey Rule397871d2025-05-19 15:02:45 +0100818 // Handler for /end - shuts down the inner sketch process
819 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
820 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700821 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Pokey Rule397871d2025-05-19 15:02:45 +0100822 return
823 }
824
825 // Parse the request body (optional)
826 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700827 Reason string `json:"reason"`
828 Happy *bool `json:"happy,omitempty"`
829 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100830 }
831
832 decoder := json.NewDecoder(r.Body)
833 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700834 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Pokey Rule397871d2025-05-19 15:02:45 +0100835 return
836 }
837 defer r.Body.Close()
838
839 endReason := "user requested end of session"
840 if requestBody.Reason != "" {
841 endReason = requestBody.Reason
842 }
843
844 // Send success response before exiting
845 w.Header().Set("Content-Type", "application/json")
846 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
847 if f, ok := w.(http.Flusher); ok {
848 f.Flush()
849 }
850
851 // Log that we're shutting down
852 slog.Info("Ending session", "reason", endReason)
853
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000854 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100855 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000856 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100857 os.Exit(0)
858 }()
859 })
860
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700861 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700862 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
863 debugMux.ServeHTTP(w, r)
864 })
865
866 return s, nil
867}
868
869// Utility functions
870func getHostname() string {
871 hostname, err := os.Hostname()
872 if err != nil {
873 return "unknown"
874 }
875 return hostname
876}
877
878func getWorkingDir() string {
879 wd, err := os.Getwd()
880 if err != nil {
881 return "unknown"
882 }
883 return wd
884}
885
886// createTerminalSession creates a new terminal session with the given ID
887func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
888 // Start a new shell process
889 shellPath := getShellPath()
890 cmd := exec.Command(shellPath)
891
892 // Get working directory from the agent if possible
893 workDir := getWorkingDir()
894 cmd.Dir = workDir
895
896 // Set up environment
897 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
898
899 // Start the command with a pty
900 ptmx, err := pty.Start(cmd)
901 if err != nil {
902 slog.Error("Failed to start pty", "error", err)
903 return nil, err
904 }
905
906 // Create the terminal session
907 session := &terminalSession{
908 pty: ptmx,
909 eventsClients: make(map[chan []byte]bool),
910 cmd: cmd,
911 }
912
913 // Start goroutine to read from pty and broadcast to all connected SSE clients
914 go s.readFromPtyAndBroadcast(sessionID, session)
915
916 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000917}
918
919// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700920func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
921 // Check if the session exists, if not, create it
922 s.ptyMutex.Lock()
923 session, exists := s.terminalSessions[sessionID]
924
925 if !exists {
926 // Create a new terminal session
927 var err error
928 session, err = s.createTerminalSession(sessionID)
929 if err != nil {
930 s.ptyMutex.Unlock()
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700931 httpError(w, r, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700932 return
933 }
934
935 // Store the new session
936 s.terminalSessions[sessionID] = session
937 }
938 s.ptyMutex.Unlock()
939
940 // Set headers for SSE
941 w.Header().Set("Content-Type", "text/event-stream")
942 w.Header().Set("Cache-Control", "no-cache")
943 w.Header().Set("Connection", "keep-alive")
944 w.Header().Set("Access-Control-Allow-Origin", "*")
945
946 // Create a channel for this client
947 events := make(chan []byte, 4096) // Buffer to prevent blocking
948
949 // Register this client's channel
950 session.eventsClientsMutex.Lock()
951 clientID := session.lastEventClientID + 1
952 session.lastEventClientID = clientID
953 session.eventsClients[events] = true
954 session.eventsClientsMutex.Unlock()
955
956 // When the client disconnects, remove their channel
957 defer func() {
958 session.eventsClientsMutex.Lock()
959 delete(session.eventsClients, events)
960 close(events)
961 session.eventsClientsMutex.Unlock()
962 }()
963
964 // Flush to send headers to client immediately
965 if f, ok := w.(http.Flusher); ok {
966 f.Flush()
967 }
968
969 // Send events to the client as they arrive
970 for {
971 select {
972 case <-r.Context().Done():
973 return
974 case data := <-events:
975 // Format as SSE with base64 encoding
976 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
977
978 // Flush the data immediately
979 if f, ok := w.(http.Flusher); ok {
980 f.Flush()
981 }
982 }
983 }
984}
985
986// handleTerminalInput processes input to the terminal
987func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
988 // Check if the session exists
989 s.ptyMutex.Lock()
990 session, exists := s.terminalSessions[sessionID]
991 s.ptyMutex.Unlock()
992
993 if !exists {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700994 httpError(w, r, "Terminal session not found", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -0700995 return
996 }
997
998 // Read the request body (terminal input or resize command)
999 body, err := io.ReadAll(r.Body)
1000 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001001 httpError(w, r, "Failed to read request body", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -07001002 return
1003 }
1004
1005 // Check if it's a resize message
1006 if len(body) > 0 && body[0] == '{' {
1007 var msg TerminalMessage
1008 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
1009 if msg.Cols > 0 && msg.Rows > 0 {
1010 pty.Setsize(session.pty, &pty.Winsize{
1011 Cols: msg.Cols,
1012 Rows: msg.Rows,
1013 })
1014
1015 // Respond with success
1016 w.WriteHeader(http.StatusOK)
1017 return
1018 }
1019 }
1020 }
1021
1022 // Regular terminal input
1023 _, err = session.pty.Write(body)
1024 if err != nil {
1025 slog.Error("Failed to write to pty", "error", err)
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001026 httpError(w, r, "Failed to write to terminal", http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -07001027 return
1028 }
1029
1030 // Respond with success
1031 w.WriteHeader(http.StatusOK)
1032}
1033
1034// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1035func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1036 buf := make([]byte, 4096)
1037 defer func() {
1038 // Clean up when done
1039 s.ptyMutex.Lock()
1040 delete(s.terminalSessions, sessionID)
1041 s.ptyMutex.Unlock()
1042
1043 // Close the PTY
1044 session.pty.Close()
1045
1046 // Ensure process is terminated
1047 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001048 session.cmd.Process.Kill()
1049 }
David Crawshawb8431462025-07-09 13:10:32 +10001050 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001051
1052 // Close all client channels
1053 session.eventsClientsMutex.Lock()
1054 for ch := range session.eventsClients {
1055 delete(session.eventsClients, ch)
1056 close(ch)
1057 }
1058 session.eventsClientsMutex.Unlock()
1059 }()
1060
1061 for {
1062 n, err := session.pty.Read(buf)
1063 if err != nil {
1064 if err != io.EOF {
1065 slog.Error("Failed to read from pty", "error", err)
1066 }
1067 break
1068 }
1069
1070 // Make a copy of the data for each client
1071 data := make([]byte, n)
1072 copy(data, buf[:n])
1073
1074 // Broadcast to all connected clients
1075 session.eventsClientsMutex.Lock()
1076 for ch := range session.eventsClients {
1077 // Try to send, but don't block if channel is full
1078 select {
1079 case ch <- data:
1080 default:
1081 // Channel is full, drop the message for this client
1082 }
1083 }
1084 session.eventsClientsMutex.Unlock()
1085 }
1086}
1087
1088// getShellPath returns the path to the shell to use
1089func getShellPath() string {
1090 // Try to use the user's preferred shell
1091 shell := os.Getenv("SHELL")
1092 if shell != "" {
1093 return shell
1094 }
1095
1096 // Default to bash on Unix-like systems
1097 if _, err := os.Stat("/bin/bash"); err == nil {
1098 return "/bin/bash"
1099 }
1100
1101 // Fall back to sh
1102 return "/bin/sh"
1103}
1104
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001105func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001107 build := "unknown build"
1108 bi, ok := debug.ReadBuildInfo()
1109 if ok {
1110 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1111 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001112 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1113 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001114 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 fmt.Fprintf(w, `<!doctype html>
1116 <html><head><title>sketch debug</title></head><body>
1117 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001118 pid %d<br>
1119 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001120 <ul>
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001121 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1122 <li><a href="pprof/profile">pprof/profile</a></li>
1123 <li><a href="pprof/symbol">pprof/symbol</a></li>
1124 <li><a href="pprof/trace">pprof/trace</a></li>
1125 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1126 <li><a href="conversation-history">conversation-history</a></li>
philz83cf6062025-07-28 14:23:04 -07001127 <li><a href="tools">tools</a></li>
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001128 <li><a href="system-prompt">system-prompt</a></li>
1129 <li><a href="logs">logs</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 </ul>
1131 </body>
1132 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001133 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001134 })
1135 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1136 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1137 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1138 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1139 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001140
1141 // Add conversation history debug handler
1142 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1143 w.Header().Set("Content-Type", "application/json")
1144
1145 // Use type assertion to access the GetConvo method
1146 type ConvoProvider interface {
1147 GetConvo() loop.ConvoInterface
1148 }
1149
1150 if convoProvider, ok := agent.(ConvoProvider); ok {
1151 // Call the DebugJSON method to get the conversation history
1152 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1153 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001154 httpError(w, r, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001155 return
1156 }
1157
1158 // Write the JSON response
1159 w.Write(historyJSON)
1160 } else {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001161 httpError(w, r, "Agent does not support conversation history debugging", http.StatusNotImplemented)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001162 }
1163 })
1164
philz83cf6062025-07-28 14:23:04 -07001165 // Add tools debug handler
1166 mux.HandleFunc("GET /debug/tools", func(w http.ResponseWriter, r *http.Request) {
1167 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1168
1169 // Try to get the conversation and its tools
1170 type ConvoProvider interface {
1171 GetConvo() loop.ConvoInterface
1172 }
1173
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001174 convoProvider, ok := agent.(ConvoProvider)
1175 if !ok {
philz83cf6062025-07-28 14:23:04 -07001176 http.Error(w, "Agent does not support conversation debugging", http.StatusNotImplemented)
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001177 return
philz83cf6062025-07-28 14:23:04 -07001178 }
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001179
1180 convoInterface := convoProvider.GetConvo()
1181 convo, ok := convoInterface.(*conversation.Convo)
1182 if !ok {
1183 http.Error(w, "Unable to access conversation tools", http.StatusInternalServerError)
1184 return
1185 }
1186
1187 // Render the tools debug page
1188 renderToolsDebugPage(w, convo.Tools)
1189 })
1190
1191 // Add system prompt debug handler
1192 mux.HandleFunc("GET /debug/system-prompt", func(w http.ResponseWriter, r *http.Request) {
1193 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1194
1195 // Try to get the conversation and its system prompt
1196 type ConvoProvider interface {
1197 GetConvo() loop.ConvoInterface
1198 }
1199
1200 convoProvider, ok := agent.(ConvoProvider)
1201 if !ok {
1202 http.Error(w, "Agent does not support conversation debugging", http.StatusNotImplemented)
1203 return
1204 }
1205
1206 convoInterface := convoProvider.GetConvo()
1207 convo, ok := convoInterface.(*conversation.Convo)
1208 if !ok {
1209 http.Error(w, "Unable to access conversation system prompt", http.StatusInternalServerError)
1210 return
1211 }
1212
1213 // Render the system prompt debug page
1214 renderSystemPromptDebugPage(w, convo.SystemPrompt)
philz83cf6062025-07-28 14:23:04 -07001215 })
1216
Earl Lee2e463fb2025-04-17 11:22:22 -07001217 return mux
1218}
1219
philz83cf6062025-07-28 14:23:04 -07001220// renderToolsDebugPage renders an HTML page showing all available tools
1221func renderToolsDebugPage(w http.ResponseWriter, tools []*llm.Tool) {
1222 fmt.Fprintf(w, `<!DOCTYPE html>
1223<html>
1224<head>
1225 <title>Sketch Tools Debug</title>
1226 <style>
1227 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 40px; }
1228 h1 { color: #333; }
1229 .tool { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 20px; margin: 20px 0; }
1230 .tool-name { font-size: 1.2em; font-weight: bold; color: #0366d6; margin-bottom: 8px; }
1231 .tool-description { color: #586069; margin-bottom: 12px; }
1232 .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; }
1233 .tool-meta { font-size: 0.9em; color: #656d76; margin-top: 8px; }
1234 .summary { background: #e6f3ff; border-left: 4px solid #0366d6; padding: 16px; margin-bottom: 30px; }
1235 </style>
1236</head>
1237<body>
1238 <h1>Sketch Tools Debug</h1>
1239 <div class="summary">
1240 <strong>Total Tools Available:</strong> %d
1241 </div>
1242`, len(tools))
1243
1244 for i, tool := range tools {
1245 fmt.Fprintf(w, ` <div class="tool">
1246 <div class="tool-name">%d. %s</div>
1247`, i+1, html.EscapeString(tool.Name))
1248
1249 if tool.Description != "" {
1250 fmt.Fprintf(w, ` <div class="tool-description">%s</div>
1251`, html.EscapeString(tool.Description))
1252 }
1253
1254 // Display schema
1255 if tool.InputSchema != nil {
1256 // Pretty print the JSON schema
1257 var schemaFormatted string
1258 if prettySchema, err := json.MarshalIndent(json.RawMessage(tool.InputSchema), "", " "); err == nil {
1259 schemaFormatted = string(prettySchema)
1260 } else {
1261 schemaFormatted = string(tool.InputSchema)
1262 }
1263 fmt.Fprintf(w, ` <div class="tool-schema">%s</div>
1264`, html.EscapeString(schemaFormatted))
1265 }
1266
1267 // Display metadata
1268 var metaParts []string
1269 if tool.Type != "" {
1270 metaParts = append(metaParts, fmt.Sprintf("Type: %s", tool.Type))
1271 }
1272 if tool.EndsTurn {
1273 metaParts = append(metaParts, "Ends Turn: true")
1274 }
1275 if len(metaParts) > 0 {
1276 fmt.Fprintf(w, ` <div class="tool-meta">%s</div>
1277`, html.EscapeString(strings.Join(metaParts, " | ")))
1278 }
1279
1280 fmt.Fprintf(w, ` </div>
1281`)
1282 }
1283
1284 fmt.Fprintf(w, `</body>
1285</html>`)
1286}
1287
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001288// SystemPromptDebugData holds the data for the system prompt debug template
1289type SystemPromptDebugData struct {
1290 SystemPrompt string
1291 Length int
1292 Lines int
1293}
1294
1295// renderSystemPromptDebugPage renders an HTML page showing the system prompt
1296func renderSystemPromptDebugPage(w http.ResponseWriter, systemPrompt string) {
1297 // Calculate stats
1298 length := len(systemPrompt)
1299 lines := strings.Count(systemPrompt, "\n") + 1
1300
1301 // Parse template
1302 tmpl, err := template.ParseFS(templateFS, "templates/system_prompt_debug.html")
1303 if err != nil {
1304 http.Error(w, "Error loading template: "+err.Error(), http.StatusInternalServerError)
1305 return
1306 }
1307
1308 // Execute template
1309 data := SystemPromptDebugData{
1310 SystemPrompt: systemPrompt,
1311 Length: length,
1312 Lines: lines,
1313 }
1314
1315 if err := tmpl.Execute(w, data); err != nil {
1316 http.Error(w, "Error executing template: "+err.Error(), http.StatusInternalServerError)
1317 }
1318}
1319
Earl Lee2e463fb2025-04-17 11:22:22 -07001320// isValidGitSHA validates if a string looks like a valid git SHA hash.
1321// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1322func isValidGitSHA(sha string) bool {
1323 // Git SHA must be a hexadecimal string with at least 4 characters
1324 if len(sha) < 4 || len(sha) > 40 {
1325 return false
1326 }
1327
1328 // Check if the string only contains hexadecimal characters
1329 for _, char := range sha {
1330 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1331 return false
1332 }
1333 }
1334
1335 return true
1336}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001337
1338// /stream?from=N endpoint for Server-Sent Events
1339func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1340 w.Header().Set("Content-Type", "text/event-stream")
1341 w.Header().Set("Cache-Control", "no-cache")
1342 w.Header().Set("Connection", "keep-alive")
1343 w.Header().Set("Access-Control-Allow-Origin", "*")
1344
1345 // Extract the 'from' parameter
1346 fromParam := r.URL.Query().Get("from")
1347 var fromIndex int
1348 var err error
1349 if fromParam != "" {
1350 fromIndex, err = strconv.Atoi(fromParam)
1351 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001352 httpError(w, r, "Invalid 'from' parameter", http.StatusBadRequest)
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001353 return
1354 }
1355 }
1356
1357 // Ensure 'from' is valid
1358 currentCount := s.agent.MessageCount()
1359 if fromIndex < 0 {
1360 fromIndex = 0
1361 } else if fromIndex > currentCount {
1362 fromIndex = currentCount
1363 }
1364
1365 // Send the current state immediately
1366 state := s.getState()
1367
1368 // Create JSON encoder
1369 encoder := json.NewEncoder(w)
1370
1371 // Send state as an event
1372 fmt.Fprintf(w, "event: state\n")
1373 fmt.Fprintf(w, "data: ")
1374 encoder.Encode(state)
1375 fmt.Fprintf(w, "\n\n")
1376
1377 if f, ok := w.(http.Flusher); ok {
1378 f.Flush()
1379 }
1380
1381 // Create a context for the SSE stream
1382 ctx := r.Context()
1383
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001384 // Setup heartbeat timer
1385 heartbeatTicker := time.NewTicker(45 * time.Second)
1386 defer heartbeatTicker.Stop()
1387
1388 // Create a channel for messages
1389 messageChan := make(chan *loop.AgentMessage, 10)
1390
Philip Zeyligereab12de2025-05-14 02:35:53 +00001391 // Create a channel for state transitions
1392 stateChan := make(chan *loop.StateTransition, 10)
1393
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001394 // Start a goroutine to read messages without blocking the heartbeat
1395 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001396 // Create an iterator to receive new messages as they arrive
1397 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1398 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001399 defer close(messageChan)
1400 for {
1401 // This can block, but it's in its own goroutine
1402 newMessage := iterator.Next()
1403 if newMessage == nil {
1404 // No message available (likely due to context cancellation)
1405 slog.InfoContext(ctx, "No more messages available, ending message stream")
1406 return
1407 }
1408
1409 select {
1410 case messageChan <- newMessage:
1411 // Message sent to channel
1412 case <-ctx.Done():
1413 // Context cancelled
1414 return
1415 }
1416 }
1417 }()
1418
Philip Zeyligereab12de2025-05-14 02:35:53 +00001419 // Start a goroutine to read state transitions
1420 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001421 // Create an iterator to receive state transitions
1422 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1423 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001424 defer close(stateChan)
1425 for {
1426 // This can block, but it's in its own goroutine
1427 newTransition := stateIterator.Next()
1428 if newTransition == nil {
1429 // No transition available (likely due to context cancellation)
1430 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1431 return
1432 }
1433
1434 select {
1435 case stateChan <- newTransition:
1436 // Transition sent to channel
1437 case <-ctx.Done():
1438 // Context cancelled
1439 return
1440 }
1441 }
1442 }()
1443
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001444 // Stay connected and stream real-time updates
1445 for {
1446 select {
1447 case <-heartbeatTicker.C:
1448 // Send heartbeat event
1449 fmt.Fprintf(w, "event: heartbeat\n")
1450 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1451
1452 // Flush to send the heartbeat immediately
1453 if f, ok := w.(http.Flusher); ok {
1454 f.Flush()
1455 }
1456
1457 case <-ctx.Done():
1458 // Client disconnected
1459 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1460 return
1461
Philip Zeyligereab12de2025-05-14 02:35:53 +00001462 case _, ok := <-stateChan:
1463 if !ok {
1464 // Channel closed
1465 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1466 return
1467 }
1468
1469 // Get updated state
1470 state = s.getState()
1471
1472 // Send updated state after the state transition
1473 fmt.Fprintf(w, "event: state\n")
1474 fmt.Fprintf(w, "data: ")
1475 encoder.Encode(state)
1476 fmt.Fprintf(w, "\n\n")
1477
1478 // Flush to send the state immediately
1479 if f, ok := w.(http.Flusher); ok {
1480 f.Flush()
1481 }
1482
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001483 case newMessage, ok := <-messageChan:
1484 if !ok {
1485 // Channel closed
1486 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1487 return
1488 }
1489
1490 // Send the new message as an event
1491 fmt.Fprintf(w, "event: message\n")
1492 fmt.Fprintf(w, "data: ")
1493 encoder.Encode(newMessage)
1494 fmt.Fprintf(w, "\n\n")
1495
1496 // Get updated state
1497 state = s.getState()
1498
1499 // Send updated state after the message
1500 fmt.Fprintf(w, "event: state\n")
1501 fmt.Fprintf(w, "data: ")
1502 encoder.Encode(state)
1503 fmt.Fprintf(w, "\n\n")
1504
1505 // Flush to send the message and state immediately
1506 if f, ok := w.(http.Flusher); ok {
1507 f.Flush()
1508 }
1509 }
1510 }
1511}
1512
1513// Helper function to get the current state
1514func (s *Server) getState() State {
1515 serverMessageCount := s.agent.MessageCount()
1516 totalUsage := s.agent.TotalUsage()
1517
Philip Zeyliger64f60462025-06-16 13:57:10 -07001518 // Get diff stats
1519 diffAdded, diffRemoved := s.agent.DiffStats()
1520
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001521 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001522 StateVersion: 2,
1523 MessageCount: serverMessageCount,
1524 TotalUsage: &totalUsage,
1525 Hostname: s.hostname,
1526 WorkingDir: getWorkingDir(),
1527 // TODO: Rename this field to sketch-base?
1528 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001529 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001530 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001531 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001532 OS: s.agent.OS(),
1533 OutsideHostname: s.agent.OutsideHostname(),
1534 InsideHostname: s.hostname,
1535 OutsideOS: s.agent.OutsideOS(),
1536 InsideOS: s.agent.OS(),
1537 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1538 InsideWorkingDir: getWorkingDir(),
1539 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001540 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001541 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1542 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1543 SessionID: s.agent.SessionID(),
1544 SSHAvailable: s.sshAvailable,
1545 SSHError: s.sshError,
1546 InContainer: s.agent.IsInContainer(),
1547 FirstMessageIndex: s.agent.FirstMessageIndex(),
1548 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001549 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001550 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001551 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001552 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001553 DiffLinesAdded: diffAdded,
1554 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001555 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001556 TokenContextWindow: s.agent.TokenContextWindow(),
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001557 Model: s.agent.ModelName(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001558 }
1559}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001560
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001561// getOpenPorts retrieves the current open ports from the agent
1562func (s *Server) getOpenPorts() []Port {
1563 ports := s.agent.GetPorts()
1564 if ports == nil {
1565 return nil
1566 }
1567
1568 result := make([]Port, len(ports))
1569 for i, port := range ports {
1570 result[i] = Port{
1571 Proto: port.Proto,
1572 Port: port.Port,
1573 Process: port.Process,
1574 Pid: port.Pid,
1575 }
1576 }
1577 return result
1578}
1579
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001580func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1581 if r.Method != "GET" {
1582 w.WriteHeader(http.StatusMethodNotAllowed)
1583 return
1584 }
1585
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001586 // Get the git repository root directory from agent
1587 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001588
1589 // Parse query parameters
1590 query := r.URL.Query()
1591 commit := query.Get("commit")
1592 from := query.Get("from")
1593 to := query.Get("to")
1594
1595 // If commit is specified, use commit^ and commit as from and to
1596 if commit != "" {
1597 from = commit + "^"
1598 to = commit
1599 }
1600
1601 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001602 if from == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001603 httpError(w, r, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001604 return
1605 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001606 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001607
1608 // Call the git_tools function
1609 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1610 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001611 httpError(w, r, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001612 return
1613 }
1614
1615 // Return the result as JSON
1616 w.Header().Set("Content-Type", "application/json")
1617 if err := json.NewEncoder(w).Encode(diff); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001618 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001619 return
1620 }
1621}
1622
1623func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1624 if r.Method != "GET" {
1625 w.WriteHeader(http.StatusMethodNotAllowed)
1626 return
1627 }
1628
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001629 // Get the git repository root directory from agent
1630 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001631
1632 // Parse query parameters
1633 hash := r.URL.Query().Get("hash")
1634 if hash == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001635 httpError(w, r, "Missing required parameter: 'hash'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001636 return
1637 }
1638
1639 // Call the git_tools function
1640 show, err := git_tools.GitShow(repoDir, hash)
1641 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001642 httpError(w, r, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001643 return
1644 }
1645
1646 // Create a JSON response
1647 response := map[string]string{
1648 "hash": hash,
1649 "output": show,
1650 }
1651
1652 // Return the result as JSON
1653 w.Header().Set("Content-Type", "application/json")
1654 if err := json.NewEncoder(w).Encode(response); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001655 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001656 return
1657 }
1658}
1659
1660func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1661 if r.Method != "GET" {
1662 w.WriteHeader(http.StatusMethodNotAllowed)
1663 return
1664 }
1665
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001666 // Get the git repository root directory and initial commit from agent
1667 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001668 initialCommit := s.agent.SketchGitBaseRef()
1669
1670 // Call the git_tools function
1671 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1672 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001673 httpError(w, r, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001674 return
1675 }
1676
1677 // Return the result as JSON
1678 w.Header().Set("Content-Type", "application/json")
1679 if err := json.NewEncoder(w).Encode(log); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001680 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001681 return
1682 }
1683}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001684
1685func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1686 if r.Method != "GET" {
1687 w.WriteHeader(http.StatusMethodNotAllowed)
1688 return
1689 }
1690
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001691 // Get the git repository root directory from agent
1692 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001693
1694 // Parse query parameters
1695 query := r.URL.Query()
1696 path := query.Get("path")
1697
1698 // Check if path is provided
1699 if path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001700 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001701 return
1702 }
1703
1704 // Get file content using GitCat
1705 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001706 switch {
1707 case err == nil:
1708 // continued below
1709 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001710 w.WriteHeader(http.StatusNoContent)
1711 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001712 default:
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001713 httpError(w, r, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001714 return
1715 }
1716
1717 // Return the content as JSON for consistency with other endpoints
1718 w.Header().Set("Content-Type", "application/json")
1719 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001720 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001721 return
1722 }
1723}
1724
1725func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1726 if r.Method != "POST" {
1727 w.WriteHeader(http.StatusMethodNotAllowed)
1728 return
1729 }
1730
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001731 // Get the git repository root directory from agent
1732 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001733
1734 // Parse request body
1735 var requestBody struct {
1736 Path string `json:"path"`
1737 Content string `json:"content"`
1738 }
1739
1740 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001741 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001742 return
1743 }
1744 defer r.Body.Close()
1745
1746 // Check if path is provided
1747 if requestBody.Path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001748 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001749 return
1750 }
1751
1752 // Save file content using GitSaveFile
1753 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1754 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001755 httpError(w, r, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001756 return
1757 }
1758
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001759 // Auto-commit the changes
1760 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1761 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001762 httpError(w, r, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001763 return
1764 }
1765
1766 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001767 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001768 httpError(w, r, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001769 return
1770 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001771
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001772 // Return simple success response
1773 w.WriteHeader(http.StatusOK)
1774 w.Write([]byte("ok"))
1775}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001776
1777func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1778 if r.Method != "GET" {
1779 w.WriteHeader(http.StatusMethodNotAllowed)
1780 return
1781 }
1782
1783 repoDir := s.agent.RepoRoot()
1784 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1785 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001786 httpError(w, r, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001787 return
1788 }
1789
1790 w.Header().Set("Content-Type", "application/json")
1791 response := map[string][]string{
1792 "untracked_files": untrackedFiles,
1793 }
1794 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1795}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001796
1797// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1798func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1799 if r.Method != "GET" {
1800 w.WriteHeader(http.StatusMethodNotAllowed)
1801 return
1802 }
1803
1804 repoDir := s.agent.RepoRoot()
1805
1806 // Get the current HEAD commit hash and subject in one command
1807 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1808 cmd.Dir = repoDir
1809 output, err := cmd.Output()
1810 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001811 httpError(w, r, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001812 return
1813 }
1814
1815 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1816 if len(parts) != 2 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001817 httpError(w, r, "Unexpected git log output format", http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001818 return
1819 }
1820 hash := parts[0]
1821 subject := parts[1]
1822
1823 // Get list of remote names
1824 cmd = exec.Command("git", "remote")
1825 cmd.Dir = repoDir
1826 output, err = cmd.Output()
1827 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001828 httpError(w, r, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001829 return
1830 }
1831
1832 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1833
1834 remotes := make([]Remote, 0, len(remoteNames))
1835
1836 // Get URL and display name for each remote
1837 for _, remoteName := range remoteNames {
1838 cmd = exec.Command("git", "remote", "get-url", remoteName)
1839 cmd.Dir = repoDir
1840 urlOutput, err := cmd.Output()
1841 if err != nil {
1842 // Skip this remote if we can't get its URL
1843 continue
1844 }
1845 url := strings.TrimSpace(string(urlOutput))
1846
1847 // Set display name based on passthrough-upstream and remote name
1848 var displayName string
1849 var isGitHub bool
1850 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1851 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1852 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1853 isGitHub = false
1854 } else if remoteName == "origin" || remoteName == "upstream" {
1855 // Use git_origin value, simplified for GitHub URLs
1856 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1857 } else {
1858 // For other remotes, use the remote URL directly
1859 displayName, isGitHub = simplifyGitHubURL(url)
1860 }
1861
1862 remotes = append(remotes, Remote{
1863 Name: remoteName,
1864 URL: url,
1865 DisplayName: displayName,
1866 IsGitHub: isGitHub,
1867 })
1868 }
1869
1870 w.Header().Set("Content-Type", "application/json")
1871 response := GitPushInfoResponse{
1872 Hash: hash,
1873 Subject: subject,
1874 Remotes: remotes,
1875 }
1876 _ = json.NewEncoder(w).Encode(response)
1877}
1878
1879// handleGitPush handles git push operations
1880func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1881 if r.Method != "POST" {
1882 w.WriteHeader(http.StatusMethodNotAllowed)
1883 return
1884 }
1885
1886 // Parse request body
1887 var requestBody GitPushRequest
1888
1889 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001890 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001891 return
1892 }
1893 defer r.Body.Close()
1894
1895 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001896 httpError(w, r, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001897 return
1898 }
1899
1900 repoDir := s.agent.RepoRoot()
1901
1902 // Build the git push command
1903 args := []string{"push"}
1904 if requestBody.DryRun {
1905 args = append(args, "--dry-run")
1906 }
1907 if requestBody.Force {
1908 args = append(args, "--force")
1909 }
1910
1911 // Determine the target refspec
1912 var targetRef string
1913 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1914 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1915 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1916 } else {
1917 // Normal case: push to refs/heads/<branch>
1918 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1919 }
1920
1921 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1922
1923 // Log the git push command being executed
1924 slog.InfoContext(r.Context(), "executing git push command",
1925 "command", "git",
1926 "args", args,
1927 "remote", requestBody.Remote,
1928 "branch", requestBody.Branch,
1929 "commit", requestBody.Commit,
1930 "target_ref", targetRef,
1931 "dry_run", requestBody.DryRun,
1932 "force", requestBody.Force,
1933 "repo_dir", repoDir)
1934
1935 cmd := exec.Command("git", args...)
1936 cmd.Dir = repoDir
1937 // Ideally we want to pass an extra HTTP header so that the
1938 // server can know that this was likely a user initiated action
1939 // and not an agent-initiated action. However, git push weirdly
1940 // doesn't take a "-c" option, and the only handy env variable that
1941 // because a header is the user agent, so we abuse it...
1942 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1943 output, err := cmd.CombinedOutput()
1944
1945 // Log the result of the git push command
1946 if err != nil {
1947 slog.WarnContext(r.Context(), "git push command failed",
1948 "error", err,
1949 "output", string(output),
1950 "args", args)
1951 } else {
1952 slog.InfoContext(r.Context(), "git push command completed successfully",
1953 "output", string(output),
1954 "args", args)
1955 }
1956
1957 // Prepare response
1958 response := GitPushResponse{
1959 Success: err == nil,
1960 Output: string(output),
1961 DryRun: requestBody.DryRun,
1962 }
1963
1964 if err != nil {
1965 response.Error = err.Error()
1966 }
1967
1968 w.Header().Set("Content-Type", "application/json")
1969 _ = json.NewEncoder(w).Encode(response)
1970}