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/agent.go b/loop/agent.go
index 8f96924..82d823e 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -30,6 +30,7 @@
 	"sketch.dev/llm/conversation"
 	"sketch.dev/mcp"
 	"sketch.dev/skabandclient"
+	"tailscale.com/portlist"
 )
 
 const (
@@ -155,6 +156,9 @@
 
 	// SkabandAddr returns the skaband address if configured
 	SkabandAddr() string
+
+	// GetPorts returns the cached list of open TCP ports
+	GetPorts() []portlist.Port
 }
 
 type CodingAgentMessageType string
@@ -168,6 +172,7 @@
 	CommitMessageType  CodingAgentMessageType = "commit"  // for displaying git commits
 	AutoMessageType    CodingAgentMessageType = "auto"    // for automated notifications like autoformatting
 	CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
+	PortMessageType    CodingAgentMessageType = "port"    // for port monitoring events
 
 	cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
 )
@@ -430,6 +435,8 @@
 	gitOrigin string
 	// MCP manager for handling MCP server connections
 	mcpManager *mcp.MCPManager
+	// Port monitor for tracking TCP ports
+	portMonitor *PortMonitor
 
 	// Time when the current turn started (reset at the beginning of InnerLoop)
 	startOfTurn time.Time
@@ -654,6 +661,14 @@
 
 func (a *Agent) URL() string { return a.url }
 
+// GetPorts returns the cached list of open TCP ports.
+func (a *Agent) GetPorts() []portlist.Port {
+	if a.portMonitor == nil {
+		return nil
+	}
+	return a.portMonitor.GetPorts()
+}
+
 // BranchName returns the git branch name for the conversation.
 func (a *Agent) BranchName() string {
 	return a.gitState.BranchName(a.config.BranchPrefix)
@@ -1070,6 +1085,10 @@
 
 		mcpManager: mcp.NewMCPManager(),
 	}
+
+	// Initialize port monitor with 5-second interval
+	agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
+
 	return agent
 }
 
@@ -1522,11 +1541,23 @@
 }
 
 func (a *Agent) Loop(ctxOuter context.Context) {
+	// Start port monitoring
+	if a.portMonitor != nil && a.IsInContainer() {
+		if err := a.portMonitor.Start(ctxOuter); err != nil {
+			slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
+		} else {
+			slog.InfoContext(ctxOuter, "Port monitor started")
+		}
+	}
+
 	// Set up cleanup when context is done
 	defer func() {
 		if a.mcpManager != nil {
 			a.mcpManager.Close()
 		}
+		if a.portMonitor != nil && a.IsInContainer() {
+			a.portMonitor.Stop()
+		}
 	}()
 
 	for {