sketch/loop: add PortMonitor for TCP port monitoring with Agent integration

Add PortMonitor struct that uses Tailscale portlist library to monitor
open/listening TCP ports and send AgentMessage notifications to Agent
when ports are opened or closed, with cached port list access method.

When I asked Sketch to do this with the old implementation, it did
ok parsing /proc, but then it tried to conver it to ss format...
using a library seems to work ok!

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s8fc57de4b5583d34k
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index b6e2259..3f90f8b 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -104,6 +104,15 @@
 	SSHConnectionString  string                        `json:"ssh_connection_string,omitempty"` // SSH connection string for container
 	DiffLinesAdded       int                           `json:"diff_lines_added"`                // Lines added from sketch-base to HEAD
 	DiffLinesRemoved     int                           `json:"diff_lines_removed"`              // Lines removed from sketch-base to HEAD
+	OpenPorts            []Port                        `json:"open_ports,omitempty"`            // Currently open TCP ports
+}
+
+// Port represents an open TCP port
+type Port struct {
+	Proto   string `json:"proto"`   // "tcp" or "udp"
+	Port    uint16 `json:"port"`    // port number
+	Process string `json:"process"` // optional process name
+	Pid     int    `json:"pid"`     // process ID
 }
 
 type InitRequest struct {
@@ -1308,9 +1317,29 @@
 		SSHConnectionString:  s.agent.SSHConnectionString(),
 		DiffLinesAdded:       diffAdded,
 		DiffLinesRemoved:     diffRemoved,
+		OpenPorts:            s.getOpenPorts(),
 	}
 }
 
+// getOpenPorts retrieves the current open ports from the agent
+func (s *Server) getOpenPorts() []Port {
+	ports := s.agent.GetPorts()
+	if ports == nil {
+		return nil
+	}
+
+	result := make([]Port, len(ports))
+	for i, port := range ports {
+		result[i] = Port{
+			Proto:   port.Proto,
+			Port:    port.Port,
+			Process: port.Process,
+			Pid:     port.Pid,
+		}
+	}
+	return result
+}
+
 func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
 	if r.Method != "GET" {
 		w.WriteHeader(http.StatusMethodNotAllowed)
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index f6ec8c7..cd97b12 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -14,6 +14,7 @@
 	"sketch.dev/llm/conversation"
 	"sketch.dev/loop"
 	"sketch.dev/loop/server"
+	"tailscale.com/portlist"
 )
 
 // mockAgent is a mock implementation of loop.CodingAgent for testing
@@ -263,6 +264,14 @@
 func (m *mockAgent) SkabandAddr() string   { return m.skabandAddr }
 func (m *mockAgent) LinkToGitHub() bool    { return false }
 func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
+func (m *mockAgent) GetPorts() []portlist.Port {
+	// Mock returns a few test ports
+	return []portlist.Port{
+		{Proto: "tcp", Port: 22, Process: "sshd", Pid: 1234},
+		{Proto: "tcp", Port: 80, Process: "nginx", Pid: 5678},
+		{Proto: "tcp", Port: 8080, Process: "test-server", Pid: 9012},
+	}
+}
 
 // TestSSEStream tests the SSE stream endpoint
 func TestSSEStream(t *testing.T) {
@@ -588,3 +597,79 @@
 		})
 	}
 }
+
+// TestStateEndpointIncludesPorts tests that the /state endpoint includes port information
+func TestStateEndpointIncludesPorts(t *testing.T) {
+	mockAgent := &mockAgent{
+		messages:      []loop.AgentMessage{},
+		messageCount:  0,
+		currentState:  "initial",
+		subscribers:   []chan *loop.AgentMessage{},
+		gitUsername:   "test-user",
+		initialCommit: "abc123",
+		branchName:    "test-branch",
+		branchPrefix:  "test-",
+		workingDir:    "/tmp/test",
+		sessionID:     "test-session",
+		slug:          "test-slug",
+		skabandAddr:   "http://localhost:8080",
+	}
+
+	// Create a test server
+	server, err := server.New(mockAgent, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a test request to the /state endpoint
+	req, err := http.NewRequest("GET", "/state", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a response recorder
+	rr := httptest.NewRecorder()
+
+	// Execute the request
+	server.ServeHTTP(rr, req)
+
+	// Check the response
+	if status := rr.Code; status != http.StatusOK {
+		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
+	}
+
+	// Check that the response contains port information
+	responseBody := rr.Body.String()
+	t.Logf("Response body: %s", responseBody)
+
+	// Verify the response contains the expected ports
+	if !strings.Contains(responseBody, `"open_ports"`) {
+		t.Error("Response should contain 'open_ports' field")
+	}
+
+	if !strings.Contains(responseBody, `"port": 22`) {
+		t.Error("Response should contain port 22 from mock")
+	}
+
+	if !strings.Contains(responseBody, `"port": 80`) {
+		t.Error("Response should contain port 80 from mock")
+	}
+
+	if !strings.Contains(responseBody, `"port": 8080`) {
+		t.Error("Response should contain port 8080 from mock")
+	}
+
+	if !strings.Contains(responseBody, `"process": "sshd"`) {
+		t.Error("Response should contain process name 'sshd'")
+	}
+
+	if !strings.Contains(responseBody, `"process": "nginx"`) {
+		t.Error("Response should contain process name 'nginx'")
+	}
+
+	if !strings.Contains(responseBody, `"proto": "tcp"`) {
+		t.Error("Response should contain protocol 'tcp'")
+	}
+
+	t.Log("State endpoint includes port information correctly")
+}