blob: 4738a70f4079592bedec44a0663a6226dd0b71c3 [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
bankseanbdc68892025-07-28 17:28:13 -0700709 // Handler for POST /external - e.g. where you send messages about e.g. github workflow
710 // outcomes and other external events that the agent wouldn't otherwise be aware of.
711 s.mux.HandleFunc("/external", func(w http.ResponseWriter, r *http.Request) {
712 if r.Method != http.MethodPost {
713 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
714 return
715 }
716 var msg loop.ExternalMessage
717
718 decoder := json.NewDecoder(r.Body)
719 if err := decoder.Decode(&msg); err != nil {
720 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
721 return
722 }
723 defer r.Body.Close()
724
725 if msg.TextContent == "" {
726 httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
727 return
728 }
729
730 if err := agent.ExternalMessage(r.Context(), msg); err != nil {
731 httpError(w, r, "agent ExternalMessage error: "+err.Error(), http.StatusBadRequest)
732 return
733 }
734
735 w.WriteHeader(http.StatusOK)
736 })
737
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000738 // Handler for POST /upload - uploads a file to /tmp
739 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
740 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700741 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000742 return
743 }
744
745 // Limit to 10MB file size
746 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
747
748 // Parse the multipart form
749 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700750 httpError(w, r, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000751 return
752 }
753
754 // Get the file from the multipart form
755 file, handler, err := r.FormFile("file")
756 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700757 httpError(w, r, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000758 return
759 }
760 defer file.Close()
761
762 // Generate a unique ID (8 random bytes converted to 16 hex chars)
763 randBytes := make([]byte, 8)
764 if _, err := rand.Read(randBytes); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700765 httpError(w, r, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000766 return
767 }
768
769 // Get file extension from the original filename
770 ext := filepath.Ext(handler.Filename)
771
772 // Create a unique filename in the /tmp directory
773 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
774
775 // Create the destination file
776 destFile, err := os.Create(filename)
777 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700778 httpError(w, r, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000779 return
780 }
781 defer destFile.Close()
782
783 // Copy the file contents to the destination file
784 if _, err := io.Copy(destFile, file); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700785 httpError(w, r, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000786 return
787 }
788
789 // Return the path to the saved file
790 w.Header().Set("Content-Type", "application/json")
791 json.NewEncoder(w).Encode(map[string]string{"path": filename})
792 })
793
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700794 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
795 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
796
797 // Handler for /git/push - handles git push operations
798 s.mux.HandleFunc("/git/push", s.handleGitPush)
799
Earl Lee2e463fb2025-04-17 11:22:22 -0700800 // Handler for /cancel - cancels the current inner loop in progress
801 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
802 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700803 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Earl Lee2e463fb2025-04-17 11:22:22 -0700804 return
805 }
806
807 // Parse the request body (optional)
808 var requestBody struct {
809 Reason string `json:"reason"`
810 ToolCallID string `json:"tool_call_id"`
811 }
812
813 decoder := json.NewDecoder(r.Body)
814 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700815 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 return
817 }
818 defer r.Body.Close()
819
820 cancelReason := "user requested cancellation"
821 if requestBody.Reason != "" {
822 cancelReason = requestBody.Reason
823 }
824
825 if requestBody.ToolCallID != "" {
826 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
827 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700828 httpError(w, r, err.Error(), http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -0700829 return
830 }
831 // Return a success response
832 w.Header().Set("Content-Type", "application/json")
833 json.NewEncoder(w).Encode(map[string]string{
834 "status": "cancelled",
835 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700836 "reason": cancelReason,
837 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 return
839 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000840 // Call the CancelTurn method
841 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700842 // Return a success response
843 w.Header().Set("Content-Type", "application/json")
844 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
845 })
846
Pokey Rule397871d2025-05-19 15:02:45 +0100847 // Handler for /end - shuts down the inner sketch process
848 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
849 if r.Method != http.MethodPost {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700850 httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
Pokey Rule397871d2025-05-19 15:02:45 +0100851 return
852 }
853
854 // Parse the request body (optional)
855 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700856 Reason string `json:"reason"`
857 Happy *bool `json:"happy,omitempty"`
858 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100859 }
860
861 decoder := json.NewDecoder(r.Body)
862 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700863 httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
Pokey Rule397871d2025-05-19 15:02:45 +0100864 return
865 }
866 defer r.Body.Close()
867
868 endReason := "user requested end of session"
869 if requestBody.Reason != "" {
870 endReason = requestBody.Reason
871 }
872
873 // Send success response before exiting
874 w.Header().Set("Content-Type", "application/json")
875 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
876 if f, ok := w.(http.Flusher); ok {
877 f.Flush()
878 }
879
880 // Log that we're shutting down
881 slog.Info("Ending session", "reason", endReason)
882
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000883 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100884 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000885 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100886 os.Exit(0)
887 }()
888 })
889
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700890 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700891 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
892 debugMux.ServeHTTP(w, r)
893 })
894
895 return s, nil
896}
897
898// Utility functions
899func getHostname() string {
900 hostname, err := os.Hostname()
901 if err != nil {
902 return "unknown"
903 }
904 return hostname
905}
906
907func getWorkingDir() string {
908 wd, err := os.Getwd()
909 if err != nil {
910 return "unknown"
911 }
912 return wd
913}
914
915// createTerminalSession creates a new terminal session with the given ID
916func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
917 // Start a new shell process
918 shellPath := getShellPath()
919 cmd := exec.Command(shellPath)
920
921 // Get working directory from the agent if possible
922 workDir := getWorkingDir()
923 cmd.Dir = workDir
924
925 // Set up environment
926 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
927
928 // Start the command with a pty
929 ptmx, err := pty.Start(cmd)
930 if err != nil {
931 slog.Error("Failed to start pty", "error", err)
932 return nil, err
933 }
934
935 // Create the terminal session
936 session := &terminalSession{
937 pty: ptmx,
938 eventsClients: make(map[chan []byte]bool),
939 cmd: cmd,
940 }
941
942 // Start goroutine to read from pty and broadcast to all connected SSE clients
943 go s.readFromPtyAndBroadcast(sessionID, session)
944
945 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000946}
947
948// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700949func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
950 // Check if the session exists, if not, create it
951 s.ptyMutex.Lock()
952 session, exists := s.terminalSessions[sessionID]
953
954 if !exists {
955 // Create a new terminal session
956 var err error
957 session, err = s.createTerminalSession(sessionID)
958 if err != nil {
959 s.ptyMutex.Unlock()
Philip Zeyligere34ffd62025-07-25 13:20:49 -0700960 httpError(w, r, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -0700961 return
962 }
963
964 // Store the new session
965 s.terminalSessions[sessionID] = session
966 }
967 s.ptyMutex.Unlock()
968
969 // Set headers for SSE
970 w.Header().Set("Content-Type", "text/event-stream")
971 w.Header().Set("Cache-Control", "no-cache")
972 w.Header().Set("Connection", "keep-alive")
973 w.Header().Set("Access-Control-Allow-Origin", "*")
974
975 // Create a channel for this client
976 events := make(chan []byte, 4096) // Buffer to prevent blocking
977
978 // Register this client's channel
979 session.eventsClientsMutex.Lock()
980 clientID := session.lastEventClientID + 1
981 session.lastEventClientID = clientID
982 session.eventsClients[events] = true
983 session.eventsClientsMutex.Unlock()
984
985 // When the client disconnects, remove their channel
986 defer func() {
987 session.eventsClientsMutex.Lock()
988 delete(session.eventsClients, events)
989 close(events)
990 session.eventsClientsMutex.Unlock()
991 }()
992
993 // Flush to send headers to client immediately
994 if f, ok := w.(http.Flusher); ok {
995 f.Flush()
996 }
997
998 // Send events to the client as they arrive
999 for {
1000 select {
1001 case <-r.Context().Done():
1002 return
1003 case data := <-events:
1004 // Format as SSE with base64 encoding
1005 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
1006
1007 // Flush the data immediately
1008 if f, ok := w.(http.Flusher); ok {
1009 f.Flush()
1010 }
1011 }
1012 }
1013}
1014
1015// handleTerminalInput processes input to the terminal
1016func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
1017 // Check if the session exists
1018 s.ptyMutex.Lock()
1019 session, exists := s.terminalSessions[sessionID]
1020 s.ptyMutex.Unlock()
1021
1022 if !exists {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001023 httpError(w, r, "Terminal session not found", http.StatusNotFound)
Earl Lee2e463fb2025-04-17 11:22:22 -07001024 return
1025 }
1026
1027 // Read the request body (terminal input or resize command)
1028 body, err := io.ReadAll(r.Body)
1029 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001030 httpError(w, r, "Failed to read request body", http.StatusBadRequest)
Earl Lee2e463fb2025-04-17 11:22:22 -07001031 return
1032 }
1033
1034 // Check if it's a resize message
1035 if len(body) > 0 && body[0] == '{' {
1036 var msg TerminalMessage
1037 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
1038 if msg.Cols > 0 && msg.Rows > 0 {
1039 pty.Setsize(session.pty, &pty.Winsize{
1040 Cols: msg.Cols,
1041 Rows: msg.Rows,
1042 })
1043
1044 // Respond with success
1045 w.WriteHeader(http.StatusOK)
1046 return
1047 }
1048 }
1049 }
1050
1051 // Regular terminal input
1052 _, err = session.pty.Write(body)
1053 if err != nil {
1054 slog.Error("Failed to write to pty", "error", err)
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001055 httpError(w, r, "Failed to write to terminal", http.StatusInternalServerError)
Earl Lee2e463fb2025-04-17 11:22:22 -07001056 return
1057 }
1058
1059 // Respond with success
1060 w.WriteHeader(http.StatusOK)
1061}
1062
1063// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1064func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1065 buf := make([]byte, 4096)
1066 defer func() {
1067 // Clean up when done
1068 s.ptyMutex.Lock()
1069 delete(s.terminalSessions, sessionID)
1070 s.ptyMutex.Unlock()
1071
1072 // Close the PTY
1073 session.pty.Close()
1074
1075 // Ensure process is terminated
1076 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001077 session.cmd.Process.Kill()
1078 }
David Crawshawb8431462025-07-09 13:10:32 +10001079 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001080
1081 // Close all client channels
1082 session.eventsClientsMutex.Lock()
1083 for ch := range session.eventsClients {
1084 delete(session.eventsClients, ch)
1085 close(ch)
1086 }
1087 session.eventsClientsMutex.Unlock()
1088 }()
1089
1090 for {
1091 n, err := session.pty.Read(buf)
1092 if err != nil {
1093 if err != io.EOF {
1094 slog.Error("Failed to read from pty", "error", err)
1095 }
1096 break
1097 }
1098
1099 // Make a copy of the data for each client
1100 data := make([]byte, n)
1101 copy(data, buf[:n])
1102
1103 // Broadcast to all connected clients
1104 session.eventsClientsMutex.Lock()
1105 for ch := range session.eventsClients {
1106 // Try to send, but don't block if channel is full
1107 select {
1108 case ch <- data:
1109 default:
1110 // Channel is full, drop the message for this client
1111 }
1112 }
1113 session.eventsClientsMutex.Unlock()
1114 }
1115}
1116
1117// getShellPath returns the path to the shell to use
1118func getShellPath() string {
1119 // Try to use the user's preferred shell
1120 shell := os.Getenv("SHELL")
1121 if shell != "" {
1122 return shell
1123 }
1124
1125 // Default to bash on Unix-like systems
1126 if _, err := os.Stat("/bin/bash"); err == nil {
1127 return "/bin/bash"
1128 }
1129
1130 // Fall back to sh
1131 return "/bin/sh"
1132}
1133
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001134func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001135 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001136 build := "unknown build"
1137 bi, ok := debug.ReadBuildInfo()
1138 if ok {
1139 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1140 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1142 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001143 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001144 fmt.Fprintf(w, `<!doctype html>
1145 <html><head><title>sketch debug</title></head><body>
1146 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001147 pid %d<br>
1148 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 <ul>
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001150 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1151 <li><a href="pprof/profile">pprof/profile</a></li>
1152 <li><a href="pprof/symbol">pprof/symbol</a></li>
1153 <li><a href="pprof/trace">pprof/trace</a></li>
1154 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
1155 <li><a href="conversation-history">conversation-history</a></li>
philz83cf6062025-07-28 14:23:04 -07001156 <li><a href="tools">tools</a></li>
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001157 <li><a href="system-prompt">system-prompt</a></li>
1158 <li><a href="logs">logs</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001159 </ul>
1160 </body>
1161 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001162 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 })
1164 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1165 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1166 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1167 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1168 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001169
1170 // Add conversation history debug handler
1171 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1172 w.Header().Set("Content-Type", "application/json")
1173
1174 // Use type assertion to access the GetConvo method
1175 type ConvoProvider interface {
1176 GetConvo() loop.ConvoInterface
1177 }
1178
1179 if convoProvider, ok := agent.(ConvoProvider); ok {
1180 // Call the DebugJSON method to get the conversation history
1181 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1182 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001183 httpError(w, r, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001184 return
1185 }
1186
1187 // Write the JSON response
1188 w.Write(historyJSON)
1189 } else {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001190 httpError(w, r, "Agent does not support conversation history debugging", http.StatusNotImplemented)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001191 }
1192 })
1193
philz83cf6062025-07-28 14:23:04 -07001194 // Add tools debug handler
1195 mux.HandleFunc("GET /debug/tools", func(w http.ResponseWriter, r *http.Request) {
1196 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1197
1198 // Try to get the conversation and its tools
1199 type ConvoProvider interface {
1200 GetConvo() loop.ConvoInterface
1201 }
1202
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001203 convoProvider, ok := agent.(ConvoProvider)
1204 if !ok {
philz83cf6062025-07-28 14:23:04 -07001205 http.Error(w, "Agent does not support conversation debugging", http.StatusNotImplemented)
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001206 return
philz83cf6062025-07-28 14:23:04 -07001207 }
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001208
1209 convoInterface := convoProvider.GetConvo()
1210 convo, ok := convoInterface.(*conversation.Convo)
1211 if !ok {
1212 http.Error(w, "Unable to access conversation tools", http.StatusInternalServerError)
1213 return
1214 }
1215
1216 // Render the tools debug page
1217 renderToolsDebugPage(w, convo.Tools)
1218 })
1219
1220 // Add system prompt debug handler
1221 mux.HandleFunc("GET /debug/system-prompt", func(w http.ResponseWriter, r *http.Request) {
1222 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1223
1224 // Try to get the conversation and its system prompt
1225 type ConvoProvider interface {
1226 GetConvo() loop.ConvoInterface
1227 }
1228
1229 convoProvider, ok := agent.(ConvoProvider)
1230 if !ok {
1231 http.Error(w, "Agent does not support conversation debugging", http.StatusNotImplemented)
1232 return
1233 }
1234
1235 convoInterface := convoProvider.GetConvo()
1236 convo, ok := convoInterface.(*conversation.Convo)
1237 if !ok {
1238 http.Error(w, "Unable to access conversation system prompt", http.StatusInternalServerError)
1239 return
1240 }
1241
1242 // Render the system prompt debug page
1243 renderSystemPromptDebugPage(w, convo.SystemPrompt)
philz83cf6062025-07-28 14:23:04 -07001244 })
1245
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 return mux
1247}
1248
philz83cf6062025-07-28 14:23:04 -07001249// renderToolsDebugPage renders an HTML page showing all available tools
1250func renderToolsDebugPage(w http.ResponseWriter, tools []*llm.Tool) {
1251 fmt.Fprintf(w, `<!DOCTYPE html>
1252<html>
1253<head>
1254 <title>Sketch Tools Debug</title>
1255 <style>
1256 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 40px; }
1257 h1 { color: #333; }
1258 .tool { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 20px; margin: 20px 0; }
1259 .tool-name { font-size: 1.2em; font-weight: bold; color: #0366d6; margin-bottom: 8px; }
Josh Bleecher Snyder64aaf902025-07-31 01:26:16 +00001260 .tool-description { color: #586069; margin-bottom: 12px; white-space: pre-wrap; font-family: 'SF Mono', Monaco, monospace; }
Josh Bleecher Snyder112c60d2025-07-31 01:35:43 +00001261 .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; white-space: pre; }
philz83cf6062025-07-28 14:23:04 -07001262 .tool-meta { font-size: 0.9em; color: #656d76; margin-top: 8px; }
1263 .summary { background: #e6f3ff; border-left: 4px solid #0366d6; padding: 16px; margin-bottom: 30px; }
1264 </style>
1265</head>
1266<body>
1267 <h1>Sketch Tools Debug</h1>
1268 <div class="summary">
1269 <strong>Total Tools Available:</strong> %d
1270 </div>
1271`, len(tools))
1272
1273 for i, tool := range tools {
1274 fmt.Fprintf(w, ` <div class="tool">
1275 <div class="tool-name">%d. %s</div>
1276`, i+1, html.EscapeString(tool.Name))
1277
1278 if tool.Description != "" {
Josh Bleecher Snyder64aaf902025-07-31 01:26:16 +00001279 fmt.Fprintf(w, ` <pre class="tool-description">%s</pre>
philz83cf6062025-07-28 14:23:04 -07001280`, html.EscapeString(tool.Description))
1281 }
1282
1283 // Display schema
1284 if tool.InputSchema != nil {
1285 // Pretty print the JSON schema
1286 var schemaFormatted string
1287 if prettySchema, err := json.MarshalIndent(json.RawMessage(tool.InputSchema), "", " "); err == nil {
1288 schemaFormatted = string(prettySchema)
1289 } else {
1290 schemaFormatted = string(tool.InputSchema)
1291 }
Josh Bleecher Snyder112c60d2025-07-31 01:35:43 +00001292 fmt.Fprintf(w, ` <pre class="tool-schema">%s</pre>
philz83cf6062025-07-28 14:23:04 -07001293`, html.EscapeString(schemaFormatted))
1294 }
1295
1296 // Display metadata
1297 var metaParts []string
1298 if tool.Type != "" {
1299 metaParts = append(metaParts, fmt.Sprintf("Type: %s", tool.Type))
1300 }
1301 if tool.EndsTurn {
1302 metaParts = append(metaParts, "Ends Turn: true")
1303 }
1304 if len(metaParts) > 0 {
1305 fmt.Fprintf(w, ` <div class="tool-meta">%s</div>
1306`, html.EscapeString(strings.Join(metaParts, " | ")))
1307 }
1308
1309 fmt.Fprintf(w, ` </div>
1310`)
1311 }
1312
1313 fmt.Fprintf(w, `</body>
1314</html>`)
1315}
1316
Josh Bleecher Snyder4a370aa2025-07-28 23:19:48 +00001317// SystemPromptDebugData holds the data for the system prompt debug template
1318type SystemPromptDebugData struct {
1319 SystemPrompt string
1320 Length int
1321 Lines int
1322}
1323
1324// renderSystemPromptDebugPage renders an HTML page showing the system prompt
1325func renderSystemPromptDebugPage(w http.ResponseWriter, systemPrompt string) {
1326 // Calculate stats
1327 length := len(systemPrompt)
1328 lines := strings.Count(systemPrompt, "\n") + 1
1329
1330 // Parse template
1331 tmpl, err := template.ParseFS(templateFS, "templates/system_prompt_debug.html")
1332 if err != nil {
1333 http.Error(w, "Error loading template: "+err.Error(), http.StatusInternalServerError)
1334 return
1335 }
1336
1337 // Execute template
1338 data := SystemPromptDebugData{
1339 SystemPrompt: systemPrompt,
1340 Length: length,
1341 Lines: lines,
1342 }
1343
1344 if err := tmpl.Execute(w, data); err != nil {
1345 http.Error(w, "Error executing template: "+err.Error(), http.StatusInternalServerError)
1346 }
1347}
1348
Earl Lee2e463fb2025-04-17 11:22:22 -07001349// isValidGitSHA validates if a string looks like a valid git SHA hash.
1350// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1351func isValidGitSHA(sha string) bool {
1352 // Git SHA must be a hexadecimal string with at least 4 characters
1353 if len(sha) < 4 || len(sha) > 40 {
1354 return false
1355 }
1356
1357 // Check if the string only contains hexadecimal characters
1358 for _, char := range sha {
1359 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1360 return false
1361 }
1362 }
1363
1364 return true
1365}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001366
1367// /stream?from=N endpoint for Server-Sent Events
1368func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1369 w.Header().Set("Content-Type", "text/event-stream")
1370 w.Header().Set("Cache-Control", "no-cache")
1371 w.Header().Set("Connection", "keep-alive")
1372 w.Header().Set("Access-Control-Allow-Origin", "*")
1373
1374 // Extract the 'from' parameter
1375 fromParam := r.URL.Query().Get("from")
1376 var fromIndex int
1377 var err error
1378 if fromParam != "" {
1379 fromIndex, err = strconv.Atoi(fromParam)
1380 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001381 httpError(w, r, "Invalid 'from' parameter", http.StatusBadRequest)
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001382 return
1383 }
1384 }
1385
1386 // Ensure 'from' is valid
1387 currentCount := s.agent.MessageCount()
1388 if fromIndex < 0 {
1389 fromIndex = 0
1390 } else if fromIndex > currentCount {
1391 fromIndex = currentCount
1392 }
1393
1394 // Send the current state immediately
1395 state := s.getState()
1396
1397 // Create JSON encoder
1398 encoder := json.NewEncoder(w)
1399
1400 // Send state as an event
1401 fmt.Fprintf(w, "event: state\n")
1402 fmt.Fprintf(w, "data: ")
1403 encoder.Encode(state)
1404 fmt.Fprintf(w, "\n\n")
1405
1406 if f, ok := w.(http.Flusher); ok {
1407 f.Flush()
1408 }
1409
1410 // Create a context for the SSE stream
1411 ctx := r.Context()
1412
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001413 // Setup heartbeat timer
1414 heartbeatTicker := time.NewTicker(45 * time.Second)
1415 defer heartbeatTicker.Stop()
1416
1417 // Create a channel for messages
1418 messageChan := make(chan *loop.AgentMessage, 10)
1419
Philip Zeyligereab12de2025-05-14 02:35:53 +00001420 // Create a channel for state transitions
1421 stateChan := make(chan *loop.StateTransition, 10)
1422
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001423 // Start a goroutine to read messages without blocking the heartbeat
1424 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001425 // Create an iterator to receive new messages as they arrive
1426 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1427 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001428 defer close(messageChan)
1429 for {
1430 // This can block, but it's in its own goroutine
1431 newMessage := iterator.Next()
1432 if newMessage == nil {
1433 // No message available (likely due to context cancellation)
1434 slog.InfoContext(ctx, "No more messages available, ending message stream")
1435 return
1436 }
1437
1438 select {
1439 case messageChan <- newMessage:
1440 // Message sent to channel
1441 case <-ctx.Done():
1442 // Context cancelled
1443 return
1444 }
1445 }
1446 }()
1447
Philip Zeyligereab12de2025-05-14 02:35:53 +00001448 // Start a goroutine to read state transitions
1449 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001450 // Create an iterator to receive state transitions
1451 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1452 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001453 defer close(stateChan)
1454 for {
1455 // This can block, but it's in its own goroutine
1456 newTransition := stateIterator.Next()
1457 if newTransition == nil {
1458 // No transition available (likely due to context cancellation)
1459 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1460 return
1461 }
1462
1463 select {
1464 case stateChan <- newTransition:
1465 // Transition sent to channel
1466 case <-ctx.Done():
1467 // Context cancelled
1468 return
1469 }
1470 }
1471 }()
1472
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001473 // Stay connected and stream real-time updates
1474 for {
1475 select {
1476 case <-heartbeatTicker.C:
1477 // Send heartbeat event
1478 fmt.Fprintf(w, "event: heartbeat\n")
1479 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1480
1481 // Flush to send the heartbeat immediately
1482 if f, ok := w.(http.Flusher); ok {
1483 f.Flush()
1484 }
1485
1486 case <-ctx.Done():
1487 // Client disconnected
1488 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1489 return
1490
Philip Zeyligereab12de2025-05-14 02:35:53 +00001491 case _, ok := <-stateChan:
1492 if !ok {
1493 // Channel closed
1494 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1495 return
1496 }
1497
1498 // Get updated state
1499 state = s.getState()
1500
1501 // Send updated state after the state transition
1502 fmt.Fprintf(w, "event: state\n")
1503 fmt.Fprintf(w, "data: ")
1504 encoder.Encode(state)
1505 fmt.Fprintf(w, "\n\n")
1506
1507 // Flush to send the state immediately
1508 if f, ok := w.(http.Flusher); ok {
1509 f.Flush()
1510 }
1511
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001512 case newMessage, ok := <-messageChan:
1513 if !ok {
1514 // Channel closed
1515 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1516 return
1517 }
1518
1519 // Send the new message as an event
1520 fmt.Fprintf(w, "event: message\n")
1521 fmt.Fprintf(w, "data: ")
1522 encoder.Encode(newMessage)
1523 fmt.Fprintf(w, "\n\n")
1524
1525 // Get updated state
1526 state = s.getState()
1527
1528 // Send updated state after the message
1529 fmt.Fprintf(w, "event: state\n")
1530 fmt.Fprintf(w, "data: ")
1531 encoder.Encode(state)
1532 fmt.Fprintf(w, "\n\n")
1533
1534 // Flush to send the message and state immediately
1535 if f, ok := w.(http.Flusher); ok {
1536 f.Flush()
1537 }
1538 }
1539 }
1540}
1541
1542// Helper function to get the current state
1543func (s *Server) getState() State {
1544 serverMessageCount := s.agent.MessageCount()
1545 totalUsage := s.agent.TotalUsage()
1546
Philip Zeyliger64f60462025-06-16 13:57:10 -07001547 // Get diff stats
1548 diffAdded, diffRemoved := s.agent.DiffStats()
1549
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001550 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001551 StateVersion: 2,
1552 MessageCount: serverMessageCount,
1553 TotalUsage: &totalUsage,
1554 Hostname: s.hostname,
1555 WorkingDir: getWorkingDir(),
1556 // TODO: Rename this field to sketch-base?
1557 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001558 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001559 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001560 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001561 OS: s.agent.OS(),
1562 OutsideHostname: s.agent.OutsideHostname(),
1563 InsideHostname: s.hostname,
1564 OutsideOS: s.agent.OutsideOS(),
1565 InsideOS: s.agent.OS(),
1566 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1567 InsideWorkingDir: getWorkingDir(),
1568 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001569 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001570 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1571 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1572 SessionID: s.agent.SessionID(),
1573 SSHAvailable: s.sshAvailable,
1574 SSHError: s.sshError,
1575 InContainer: s.agent.IsInContainer(),
1576 FirstMessageIndex: s.agent.FirstMessageIndex(),
1577 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001578 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001579 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001580 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001581 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001582 DiffLinesAdded: diffAdded,
1583 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001584 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001585 TokenContextWindow: s.agent.TokenContextWindow(),
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001586 Model: s.agent.ModelName(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001587 }
1588}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001589
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001590// getOpenPorts retrieves the current open ports from the agent
1591func (s *Server) getOpenPorts() []Port {
1592 ports := s.agent.GetPorts()
1593 if ports == nil {
1594 return nil
1595 }
1596
1597 result := make([]Port, len(ports))
1598 for i, port := range ports {
1599 result[i] = Port{
1600 Proto: port.Proto,
1601 Port: port.Port,
1602 Process: port.Process,
1603 Pid: port.Pid,
1604 }
1605 }
1606 return result
1607}
1608
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001609func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1610 if r.Method != "GET" {
1611 w.WriteHeader(http.StatusMethodNotAllowed)
1612 return
1613 }
1614
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001615 // Get the git repository root directory from agent
1616 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001617
1618 // Parse query parameters
1619 query := r.URL.Query()
1620 commit := query.Get("commit")
1621 from := query.Get("from")
1622 to := query.Get("to")
1623
1624 // If commit is specified, use commit^ and commit as from and to
1625 if commit != "" {
1626 from = commit + "^"
1627 to = commit
1628 }
1629
1630 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001631 if from == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001632 httpError(w, r, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001633 return
1634 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001635 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001636
1637 // Call the git_tools function
1638 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1639 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001640 httpError(w, r, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001641 return
1642 }
1643
1644 // Return the result as JSON
1645 w.Header().Set("Content-Type", "application/json")
1646 if err := json.NewEncoder(w).Encode(diff); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001647 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001648 return
1649 }
1650}
1651
1652func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1653 if r.Method != "GET" {
1654 w.WriteHeader(http.StatusMethodNotAllowed)
1655 return
1656 }
1657
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001658 // Get the git repository root directory from agent
1659 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001660
1661 // Parse query parameters
1662 hash := r.URL.Query().Get("hash")
1663 if hash == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001664 httpError(w, r, "Missing required parameter: 'hash'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001665 return
1666 }
1667
1668 // Call the git_tools function
1669 show, err := git_tools.GitShow(repoDir, hash)
1670 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001671 httpError(w, r, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001672 return
1673 }
1674
1675 // Create a JSON response
1676 response := map[string]string{
1677 "hash": hash,
1678 "output": show,
1679 }
1680
1681 // Return the result as JSON
1682 w.Header().Set("Content-Type", "application/json")
1683 if err := json.NewEncoder(w).Encode(response); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001684 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001685 return
1686 }
1687}
1688
1689func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1690 if r.Method != "GET" {
1691 w.WriteHeader(http.StatusMethodNotAllowed)
1692 return
1693 }
1694
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001695 // Get the git repository root directory and initial commit from agent
1696 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001697 initialCommit := s.agent.SketchGitBaseRef()
1698
1699 // Call the git_tools function
1700 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1701 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001702 httpError(w, r, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001703 return
1704 }
1705
1706 // Return the result as JSON
1707 w.Header().Set("Content-Type", "application/json")
1708 if err := json.NewEncoder(w).Encode(log); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001709 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001710 return
1711 }
1712}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001713
1714func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1715 if r.Method != "GET" {
1716 w.WriteHeader(http.StatusMethodNotAllowed)
1717 return
1718 }
1719
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001720 // Get the git repository root directory from agent
1721 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001722
1723 // Parse query parameters
1724 query := r.URL.Query()
1725 path := query.Get("path")
1726
1727 // Check if path is provided
1728 if path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001729 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001730 return
1731 }
1732
1733 // Get file content using GitCat
1734 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001735 switch {
1736 case err == nil:
1737 // continued below
1738 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001739 w.WriteHeader(http.StatusNoContent)
1740 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001741 default:
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001742 httpError(w, r, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001743 return
1744 }
1745
1746 // Return the content as JSON for consistency with other endpoints
1747 w.Header().Set("Content-Type", "application/json")
1748 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001749 httpError(w, r, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001750 return
1751 }
1752}
1753
1754func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1755 if r.Method != "POST" {
1756 w.WriteHeader(http.StatusMethodNotAllowed)
1757 return
1758 }
1759
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001760 // Get the git repository root directory from agent
1761 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001762
1763 // Parse request body
1764 var requestBody struct {
1765 Path string `json:"path"`
1766 Content string `json:"content"`
1767 }
1768
1769 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001770 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001771 return
1772 }
1773 defer r.Body.Close()
1774
1775 // Check if path is provided
1776 if requestBody.Path == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001777 httpError(w, r, "Missing required parameter: path", http.StatusBadRequest)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001778 return
1779 }
1780
1781 // Save file content using GitSaveFile
1782 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1783 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001784 httpError(w, r, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001785 return
1786 }
1787
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001788 // Auto-commit the changes
1789 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1790 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001791 httpError(w, r, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001792 return
1793 }
1794
1795 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001796 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001797 httpError(w, r, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001798 return
1799 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001800
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001801 // Return simple success response
1802 w.WriteHeader(http.StatusOK)
1803 w.Write([]byte("ok"))
1804}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001805
1806func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1807 if r.Method != "GET" {
1808 w.WriteHeader(http.StatusMethodNotAllowed)
1809 return
1810 }
1811
1812 repoDir := s.agent.RepoRoot()
1813 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1814 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001815 httpError(w, r, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001816 return
1817 }
1818
1819 w.Header().Set("Content-Type", "application/json")
1820 response := map[string][]string{
1821 "untracked_files": untrackedFiles,
1822 }
1823 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1824}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001825
1826// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1827func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1828 if r.Method != "GET" {
1829 w.WriteHeader(http.StatusMethodNotAllowed)
1830 return
1831 }
1832
1833 repoDir := s.agent.RepoRoot()
1834
1835 // Get the current HEAD commit hash and subject in one command
1836 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1837 cmd.Dir = repoDir
1838 output, err := cmd.Output()
1839 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001840 httpError(w, r, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001841 return
1842 }
1843
1844 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1845 if len(parts) != 2 {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001846 httpError(w, r, "Unexpected git log output format", http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001847 return
1848 }
1849 hash := parts[0]
1850 subject := parts[1]
1851
1852 // Get list of remote names
1853 cmd = exec.Command("git", "remote")
1854 cmd.Dir = repoDir
1855 output, err = cmd.Output()
1856 if err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001857 httpError(w, r, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001858 return
1859 }
1860
1861 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1862
1863 remotes := make([]Remote, 0, len(remoteNames))
1864
1865 // Get URL and display name for each remote
1866 for _, remoteName := range remoteNames {
1867 cmd = exec.Command("git", "remote", "get-url", remoteName)
1868 cmd.Dir = repoDir
1869 urlOutput, err := cmd.Output()
1870 if err != nil {
1871 // Skip this remote if we can't get its URL
1872 continue
1873 }
1874 url := strings.TrimSpace(string(urlOutput))
1875
1876 // Set display name based on passthrough-upstream and remote name
1877 var displayName string
1878 var isGitHub bool
1879 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1880 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1881 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1882 isGitHub = false
1883 } else if remoteName == "origin" || remoteName == "upstream" {
1884 // Use git_origin value, simplified for GitHub URLs
1885 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1886 } else {
1887 // For other remotes, use the remote URL directly
1888 displayName, isGitHub = simplifyGitHubURL(url)
1889 }
1890
1891 remotes = append(remotes, Remote{
1892 Name: remoteName,
1893 URL: url,
1894 DisplayName: displayName,
1895 IsGitHub: isGitHub,
1896 })
1897 }
1898
1899 w.Header().Set("Content-Type", "application/json")
1900 response := GitPushInfoResponse{
1901 Hash: hash,
1902 Subject: subject,
1903 Remotes: remotes,
1904 }
1905 _ = json.NewEncoder(w).Encode(response)
1906}
1907
1908// handleGitPush handles git push operations
1909func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1910 if r.Method != "POST" {
1911 w.WriteHeader(http.StatusMethodNotAllowed)
1912 return
1913 }
1914
1915 // Parse request body
1916 var requestBody GitPushRequest
1917
1918 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001919 httpError(w, r, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001920 return
1921 }
1922 defer r.Body.Close()
1923
1924 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
Philip Zeyligere34ffd62025-07-25 13:20:49 -07001925 httpError(w, r, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001926 return
1927 }
1928
1929 repoDir := s.agent.RepoRoot()
1930
1931 // Build the git push command
1932 args := []string{"push"}
1933 if requestBody.DryRun {
1934 args = append(args, "--dry-run")
1935 }
1936 if requestBody.Force {
1937 args = append(args, "--force")
1938 }
1939
1940 // Determine the target refspec
1941 var targetRef string
1942 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1943 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1944 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1945 } else {
1946 // Normal case: push to refs/heads/<branch>
1947 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1948 }
1949
1950 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1951
1952 // Log the git push command being executed
1953 slog.InfoContext(r.Context(), "executing git push command",
1954 "command", "git",
1955 "args", args,
1956 "remote", requestBody.Remote,
1957 "branch", requestBody.Branch,
1958 "commit", requestBody.Commit,
1959 "target_ref", targetRef,
1960 "dry_run", requestBody.DryRun,
1961 "force", requestBody.Force,
1962 "repo_dir", repoDir)
1963
1964 cmd := exec.Command("git", args...)
1965 cmd.Dir = repoDir
1966 // Ideally we want to pass an extra HTTP header so that the
1967 // server can know that this was likely a user initiated action
1968 // and not an agent-initiated action. However, git push weirdly
1969 // doesn't take a "-c" option, and the only handy env variable that
1970 // because a header is the user agent, so we abuse it...
1971 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1972 output, err := cmd.CombinedOutput()
1973
1974 // Log the result of the git push command
1975 if err != nil {
1976 slog.WarnContext(r.Context(), "git push command failed",
1977 "error", err,
1978 "output", string(output),
1979 "args", args)
1980 } else {
1981 slog.InfoContext(r.Context(), "git push command completed successfully",
1982 "output", string(output),
1983 "args", args)
1984 }
1985
1986 // Prepare response
1987 response := GitPushResponse{
1988 Success: err == nil,
1989 Output: string(output),
1990 DryRun: requestBody.DryRun,
1991 }
1992
1993 if err != nil {
1994 response.Error = err.Error()
1995 }
1996
1997 w.Header().Set("Content-Type", "application/json")
1998 _ = json.NewEncoder(w).Encode(response)
1999}