sketch: proxy to ports via p<port>.localhost Host headers

Add support for proxying requests based on Host header patterns.
When Host header matches p<port>.localhost, proxy the request to localhost:<port>.

- ParsePortProxyHost() extracts port from p8000.localhost format
- proxyToPort() handles generic port proxying with validation
- Supports any valid port (1-65535) via p<port>.localhost pattern
- Comprehensive tests for parsing and validation

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: saa324eab0e9b3addk
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index bef4982..7f31401 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -13,7 +13,9 @@
 	"io/fs"
 	"log/slog"
 	"net/http"
+	"net/http/httputil"
 	"net/http/pprof"
+	"net/url"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -131,9 +133,68 @@
 }
 
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Check if Host header matches "p<port>.localhost" pattern and proxy to that port
+	if port := s.ParsePortProxyHost(r.Host); port != "" {
+		s.proxyToPort(w, r, port)
+		return
+	}
+
 	s.mux.ServeHTTP(w, r)
 }
 
+// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
+func (s *Server) ParsePortProxyHost(host string) string {
+	// Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
+	hostname := host
+	if idx := strings.LastIndex(host, ":"); idx > 0 {
+		hostname = host[:idx]
+	}
+
+	// Check if hostname matches p<port>.localhost pattern
+	if strings.HasSuffix(hostname, ".localhost") {
+		prefix := strings.TrimSuffix(hostname, ".localhost")
+		if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
+			port := prefix[1:] // Remove 'p' prefix
+			// Basic validation - port should be numeric and in valid range
+			if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
+				return port
+			}
+		}
+	}
+
+	return ""
+}
+
+// proxyToPort proxies the request to localhost:<port>
+func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
+	// Create a reverse proxy to localhost:<port>
+	target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
+	if err != nil {
+		http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
+		return
+	}
+
+	proxy := httputil.NewSingleHostReverseProxy(target)
+
+	// Customize the Director to modify the request
+	originalDirector := proxy.Director
+	proxy.Director = func(req *http.Request) {
+		originalDirector(req)
+		// Set the target host
+		req.URL.Host = target.Host
+		req.URL.Scheme = target.Scheme
+		req.Host = target.Host
+	}
+
+	// Handle proxy errors
+	proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+		slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
+		http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
+	}
+
+	proxy.ServeHTTP(w, r)
+}
+
 // New creates a new HTTP server.
 func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
 	s := &Server{