Initial commit
diff --git a/loop/agent.go b/loop/agent.go
new file mode 100644
index 0000000..ce362e6
--- /dev/null
+++ b/loop/agent.go
@@ -0,0 +1,1124 @@
+package loop
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/exec"
+	"runtime/debug"
+	"slices"
+	"strings"
+	"sync"
+	"time"
+
+	"sketch.dev/ant"
+	"sketch.dev/claudetool"
+)
+
+const (
+	userCancelMessage = "user requested agent to stop handling responses"
+)
+
+type CodingAgent interface {
+	// Init initializes an agent inside a docker container.
+	Init(AgentInit) error
+
+	// Ready returns a channel closed after Init successfully called.
+	Ready() <-chan struct{}
+
+	// URL reports the HTTP URL of this agent.
+	URL() string
+
+	// UserMessage enqueues a message to the agent and returns immediately.
+	UserMessage(ctx context.Context, msg string)
+
+	// WaitForMessage blocks until the agent has a response to give.
+	// Use AgentMessage.EndOfTurn to help determine if you want to
+	// drain the agent.
+	WaitForMessage(ctx context.Context) AgentMessage
+
+	// Loop begins the agent loop returns only when ctx is cancelled.
+	Loop(ctx context.Context)
+
+	CancelInnerLoop(cause error)
+
+	CancelToolUse(toolUseID string, cause error) error
+
+	// Returns a subset of the agent's message history.
+	Messages(start int, end int) []AgentMessage
+
+	// Returns the current number of messages in the history
+	MessageCount() int
+
+	TotalUsage() ant.CumulativeUsage
+	OriginalBudget() ant.Budget
+
+	// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
+	WaitForMessageCount(ctx context.Context, greaterThan int)
+
+	WorkingDir() string
+
+	// Diff returns a unified diff of changes made since the agent was instantiated.
+	// If commit is non-nil, it shows the diff for just that specific commit.
+	Diff(commit *string) (string, error)
+
+	// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
+	InitialCommit() string
+
+	// Title returns the current title of the conversation.
+	Title() string
+
+	// OS returns the operating system of the client.
+	OS() string
+}
+
+type CodingAgentMessageType string
+
+const (
+	UserMessageType    CodingAgentMessageType = "user"
+	AgentMessageType   CodingAgentMessageType = "agent"
+	ErrorMessageType   CodingAgentMessageType = "error"
+	BudgetMessageType  CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
+	ToolUseMessageType CodingAgentMessageType = "tool"
+	CommitMessageType  CodingAgentMessageType = "commit" // for displaying git commits
+	AutoMessageType    CodingAgentMessageType = "auto"   // for automated notifications like autoformatting
+
+	cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
+)
+
+type AgentMessage struct {
+	Type CodingAgentMessageType `json:"type"`
+	// EndOfTurn indicates that the AI is done working and is ready for the next user input.
+	EndOfTurn bool `json:"end_of_turn"`
+
+	Content    string `json:"content"`
+	ToolName   string `json:"tool_name,omitempty"`
+	ToolInput  string `json:"input,omitempty"`
+	ToolResult string `json:"tool_result,omitempty"`
+	ToolError  bool   `json:"tool_error,omitempty"`
+	ToolCallId string `json:"tool_call_id,omitempty"`
+
+	// ToolCalls is a list of all tool calls requested in this message (name and input pairs)
+	ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+
+	// Commits is a list of git commits for a commit message
+	Commits []*GitCommit `json:"commits,omitempty"`
+
+	Timestamp            time.Time  `json:"timestamp"`
+	ConversationID       string     `json:"conversation_id"`
+	ParentConversationID *string    `json:"parent_conversation_id,omitempty"`
+	Usage                *ant.Usage `json:"usage,omitempty"`
+
+	// Message timing information
+	StartTime *time.Time     `json:"start_time,omitempty"`
+	EndTime   *time.Time     `json:"end_time,omitempty"`
+	Elapsed   *time.Duration `json:"elapsed,omitempty"`
+
+	// Turn duration - the time taken for a complete agent turn
+	TurnDuration *time.Duration `json:"turnDuration,omitempty"`
+
+	Idx int `json:"idx"`
+}
+
+// GitCommit represents a single git commit for a commit message
+type GitCommit struct {
+	Hash         string `json:"hash"`                    // Full commit hash
+	Subject      string `json:"subject"`                 // Commit subject line
+	Body         string `json:"body"`                    // Full commit message body
+	PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
+}
+
+// ToolCall represents a single tool call within an agent message
+type ToolCall struct {
+	Name       string `json:"name"`
+	Input      string `json:"input"`
+	ToolCallId string `json:"tool_call_id"`
+}
+
+func (a *AgentMessage) Attr() slog.Attr {
+	var attrs []any = []any{
+		slog.String("type", string(a.Type)),
+	}
+	if a.EndOfTurn {
+		attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
+	}
+	if a.Content != "" {
+		attrs = append(attrs, slog.String("content", a.Content))
+	}
+	if a.ToolName != "" {
+		attrs = append(attrs, slog.String("tool_name", a.ToolName))
+	}
+	if a.ToolInput != "" {
+		attrs = append(attrs, slog.String("tool_input", a.ToolInput))
+	}
+	if a.Elapsed != nil {
+		attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
+	}
+	if a.TurnDuration != nil {
+		attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
+	}
+	if a.ToolResult != "" {
+		attrs = append(attrs, slog.String("tool_result", a.ToolResult))
+	}
+	if a.ToolError {
+		attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
+	}
+	if len(a.ToolCalls) > 0 {
+		toolCallAttrs := make([]any, 0, len(a.ToolCalls))
+		for i, tc := range a.ToolCalls {
+			toolCallAttrs = append(toolCallAttrs, slog.Group(
+				fmt.Sprintf("tool_call_%d", i),
+				slog.String("name", tc.Name),
+				slog.String("input", tc.Input),
+			))
+		}
+		attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
+	}
+	if a.ConversationID != "" {
+		attrs = append(attrs, slog.String("convo_id", a.ConversationID))
+	}
+	if a.ParentConversationID != nil {
+		attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
+	}
+	if a.Usage != nil && !a.Usage.IsZero() {
+		attrs = append(attrs, a.Usage.Attr())
+	}
+	// TODO: timestamp, convo ids, idx?
+	return slog.Group("agent_message", attrs...)
+}
+
+func errorMessage(err error) AgentMessage {
+	// It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
+	if os.Getenv(("DEBUG")) == "1" {
+		return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
+	}
+
+	return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
+}
+
+func budgetMessage(err error) AgentMessage {
+	return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
+}
+
+// ConvoInterface defines the interface for conversation interactions
+type ConvoInterface interface {
+	CumulativeUsage() ant.CumulativeUsage
+	ResetBudget(ant.Budget)
+	OverBudget() error
+	SendMessage(message ant.Message) (*ant.MessageResponse, error)
+	SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+	ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
+	ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
+	CancelToolUse(toolUseID string, cause error) error
+}
+
+type Agent struct {
+	convo          ConvoInterface
+	config         AgentConfig // config for this agent
+	workingDir     string
+	repoRoot       string // workingDir may be a subdir of repoRoot
+	url            string
+	lastHEAD       string        // hash of the last HEAD that was pushed to the host (only when under docker)
+	initialCommit  string        // hash of the Git HEAD when the agent was instantiated or Init()
+	gitRemoteAddr  string        // HTTP URL of the host git repo (only when under docker)
+	ready          chan struct{} // closed when the agent is initialized (only when under docker)
+	startedAt      time.Time
+	originalBudget ant.Budget
+	title          string
+	codereview     *claudetool.CodeReviewer
+
+	// Time when the current turn started (reset at the beginning of InnerLoop)
+	startOfTurn time.Time
+
+	// Inbox - for messages from the user to the agent.
+	// sent on by UserMessage
+	// . e.g. when user types into the chat textarea
+	// read from by GatherMessages
+	inbox chan string
+
+	// Outbox
+	// sent on by pushToOutbox
+	//  via OnToolResult and OnResponse callbacks
+	// read from by WaitForMessage
+	// 	called by termui inside its repl loop.
+	outbox chan AgentMessage
+
+	// protects cancelInnerLoop
+	cancelInnerLoopMu sync.Mutex
+	// cancels potentially long-running tool_use calls or chains of them
+	cancelInnerLoop context.CancelCauseFunc
+
+	// protects following
+	mu sync.Mutex
+
+	// Stores all messages for this agent
+	history []AgentMessage
+
+	listeners []chan struct{}
+
+	// Track git commits we've already seen (by hash)
+	seenCommits map[string]bool
+}
+
+func (a *Agent) URL() string { return a.url }
+
+// Title returns the current title of the conversation.
+// If no title has been set, returns an empty string.
+func (a *Agent) Title() string {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	return a.title
+}
+
+// OS returns the operating system of the client.
+func (a *Agent) OS() string {
+	return a.config.ClientGOOS
+}
+
+// SetTitle sets the title of the conversation.
+func (a *Agent) SetTitle(title string) {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	a.title = title
+	// Notify all listeners that the state has changed
+	for _, ch := range a.listeners {
+		close(ch)
+	}
+	a.listeners = a.listeners[:0]
+}
+
+// OnToolResult implements ant.Listener.
+func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
+	m := AgentMessage{
+		Type:       ToolUseMessageType,
+		Content:    content.Text,
+		ToolResult: content.ToolResult,
+		ToolError:  content.ToolError,
+		ToolName:   toolName,
+		ToolInput:  string(toolInput),
+		ToolCallId: content.ToolUseID,
+		StartTime:  content.StartTime,
+		EndTime:    content.EndTime,
+	}
+
+	// Calculate the elapsed time if both start and end times are set
+	if content.StartTime != nil && content.EndTime != nil {
+		elapsed := content.EndTime.Sub(*content.StartTime)
+		m.Elapsed = &elapsed
+	}
+
+	m.ConversationID = convo.ID
+	if convo.Parent != nil {
+		m.ParentConversationID = &convo.Parent.ID
+	}
+	a.pushToOutbox(ctx, m)
+}
+
+// OnRequest implements ant.Listener.
+func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, msg *ant.Message) {
+	// No-op.
+	// We already get tool results from the above. We send user messages to the outbox in the agent loop.
+}
+
+// OnResponse implements ant.Listener. Responses contain messages from the LLM
+// that need to be displayed (as well as tool calls that we send along when
+// they're done). (It would be reasonable to also mention tool calls when they're
+// started, but we don't do that yet.)
+func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, resp *ant.MessageResponse) {
+	endOfTurn := false
+	if resp.StopReason != ant.StopReasonToolUse {
+		endOfTurn = true
+	}
+	m := AgentMessage{
+		Type:      AgentMessageType,
+		Content:   collectTextContent(resp),
+		EndOfTurn: endOfTurn,
+		Usage:     &resp.Usage,
+		StartTime: resp.StartTime,
+		EndTime:   resp.EndTime,
+	}
+
+	// Extract any tool calls from the response
+	if resp.StopReason == ant.StopReasonToolUse {
+		var toolCalls []ToolCall
+		for _, part := range resp.Content {
+			if part.Type == "tool_use" {
+				toolCalls = append(toolCalls, ToolCall{
+					Name:       part.ToolName,
+					Input:      string(part.ToolInput),
+					ToolCallId: part.ID,
+				})
+			}
+		}
+		m.ToolCalls = toolCalls
+	}
+
+	// Calculate the elapsed time if both start and end times are set
+	if resp.StartTime != nil && resp.EndTime != nil {
+		elapsed := resp.EndTime.Sub(*resp.StartTime)
+		m.Elapsed = &elapsed
+	}
+
+	m.ConversationID = convo.ID
+	if convo.Parent != nil {
+		m.ParentConversationID = &convo.Parent.ID
+	}
+	a.pushToOutbox(ctx, m)
+}
+
+// WorkingDir implements CodingAgent.
+func (a *Agent) WorkingDir() string {
+	return a.workingDir
+}
+
+// MessageCount implements CodingAgent.
+func (a *Agent) MessageCount() int {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	return len(a.history)
+}
+
+// Messages implements CodingAgent.
+func (a *Agent) Messages(start int, end int) []AgentMessage {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	return slices.Clone(a.history[start:end])
+}
+
+func (a *Agent) OriginalBudget() ant.Budget {
+	return a.originalBudget
+}
+
+// AgentConfig contains configuration for creating a new Agent.
+type AgentConfig struct {
+	Context          context.Context
+	AntURL           string
+	APIKey           string
+	HTTPC            *http.Client
+	Budget           ant.Budget
+	GitUsername      string
+	GitEmail         string
+	SessionID        string
+	ClientGOOS       string
+	ClientGOARCH     string
+	UseAnthropicEdit bool
+}
+
+// NewAgent creates a new Agent.
+// It is not usable until Init() is called.
+func NewAgent(config AgentConfig) *Agent {
+	agent := &Agent{
+		config:         config,
+		ready:          make(chan struct{}),
+		inbox:          make(chan string, 100),
+		outbox:         make(chan AgentMessage, 100),
+		startedAt:      time.Now(),
+		originalBudget: config.Budget,
+		seenCommits:    make(map[string]bool),
+	}
+	return agent
+}
+
+type AgentInit struct {
+	WorkingDir string
+	NoGit      bool // only for testing
+
+	InDocker      bool
+	Commit        string
+	GitRemoteAddr string
+	HostAddr      string
+}
+
+func (a *Agent) Init(ini AgentInit) error {
+	ctx := a.config.Context
+	if ini.InDocker {
+		cmd := exec.CommandContext(ctx, "git", "stash")
+		cmd.Dir = ini.WorkingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("git stash: %s: %v", out, err)
+		}
+		cmd = exec.CommandContext(ctx, "git", "fetch", ini.GitRemoteAddr)
+		cmd.Dir = ini.WorkingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("git fetch: %s: %w", out, err)
+		}
+		cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
+		cmd.Dir = ini.WorkingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
+		}
+		a.lastHEAD = ini.Commit
+		a.gitRemoteAddr = ini.GitRemoteAddr
+		a.initialCommit = ini.Commit
+		if ini.HostAddr != "" {
+			a.url = "http://" + ini.HostAddr
+		}
+	}
+	a.workingDir = ini.WorkingDir
+
+	if !ini.NoGit {
+		repoRoot, err := repoRoot(ctx, a.workingDir)
+		if err != nil {
+			return fmt.Errorf("repoRoot: %w", err)
+		}
+		a.repoRoot = repoRoot
+
+		commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
+		if err != nil {
+			return fmt.Errorf("resolveRef: %w", err)
+		}
+		a.initialCommit = commitHash
+
+		codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
+		if err != nil {
+			return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
+		}
+		a.codereview = codereview
+	}
+	a.lastHEAD = a.initialCommit
+	a.convo = a.initConvo()
+	close(a.ready)
+	return nil
+}
+
+// initConvo initializes the conversation.
+// It must not be called until all agent fields are initialized,
+// particularly workingDir and git.
+func (a *Agent) initConvo() *ant.Convo {
+	ctx := a.config.Context
+	convo := ant.NewConvo(ctx, a.config.APIKey)
+	if a.config.HTTPC != nil {
+		convo.HTTPC = a.config.HTTPC
+	}
+	if a.config.AntURL != "" {
+		convo.URL = a.config.AntURL
+	}
+	convo.PromptCaching = true
+	convo.Budget = a.config.Budget
+
+	var editPrompt string
+	if a.config.UseAnthropicEdit {
+		editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
+	} else {
+		editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
+	}
+
+	convo.SystemPrompt = fmt.Sprintf(`
+You are an expert coding assistant and architect, with a specialty in Go.
+You are assisting the user to achieve their goals.
+
+Start by asking concise clarifying questions as needed.
+Once the intent is clear, work autonomously.
+
+Call the title tool early in the conversation to provide a brief summary of
+what the chat is about.
+
+Break down the overall goal into a series of smaller steps.
+(The first step is often: "Make a plan.")
+Then execute each step using tools.
+Update the plan if you have encountered problems or learned new information.
+
+When in doubt about a step, follow this broad workflow:
+
+- Think about how the current step fits into the overall plan.
+- Do research. Good tool choices: bash, think, keyword_search
+- Make edits.
+- Repeat.
+
+To make edits reliably and efficiently, first think about the intent of the edit,
+and what set of patches will achieve that intent.
+%s
+
+For renames or refactors, consider invoking gopls (via bash).
+
+The done tool provides a checklist of items you MUST verify and
+review before declaring that you are done. Before executing
+the done tool, run all the tools the done tool checklist asks
+for, including creating a git commit. Do not forget to run tests.
+
+<platform>
+%s/%s
+</platform>
+<pwd>
+%v
+</pwd>
+<git_root>
+%v
+</git_root>
+`, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot)
+
+	// Register all tools with the conversation
+	// When adding, removing, or modifying tools here, double-check that the termui tool display
+	// template in termui/termui.go has pretty-printing support for all tools.
+	convo.Tools = []*ant.Tool{
+		claudetool.Bash, claudetool.Keyword,
+		claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
+		a.codereview.Tool(),
+	}
+	if a.config.UseAnthropicEdit {
+		convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
+	} else {
+		convo.Tools = append(convo.Tools, claudetool.Patch)
+	}
+	convo.Listener = a
+	return convo
+}
+
+func (a *Agent) titleTool() *ant.Tool {
+	// titleTool creates the title tool that sets the conversation title.
+	title := &ant.Tool{
+		Name:        "title",
+		Description: `Use this tool early in the conversation, BEFORE MAKING ANY GIT COMMITS, to summarize what the chat is about briefly.`,
+		InputSchema: json.RawMessage(`{
+	"type": "object",
+	"properties": {
+		"title": {
+			"type": "string",
+			"description": "A brief title summarizing what this chat is about"
+		}
+	},
+	"required": ["title"]
+}`),
+		Run: func(ctx context.Context, input json.RawMessage) (string, error) {
+			var params struct {
+				Title string `json:"title"`
+			}
+			if err := json.Unmarshal(input, &params); err != nil {
+				return "", err
+			}
+			a.SetTitle(params.Title)
+			return fmt.Sprintf("Title set to: %s", params.Title), nil
+		},
+	}
+	return title
+}
+
+func (a *Agent) Ready() <-chan struct{} {
+	return a.ready
+}
+
+func (a *Agent) UserMessage(ctx context.Context, msg string) {
+	a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
+	a.inbox <- msg
+}
+
+func (a *Agent) WaitForMessage(ctx context.Context) AgentMessage {
+	// TODO: Should this drain any outbox messages in case there are multiple?
+	select {
+	case msg := <-a.outbox:
+		return msg
+	case <-ctx.Done():
+		return errorMessage(ctx.Err())
+	}
+}
+
+func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
+	return a.convo.CancelToolUse(toolUseID, cause)
+}
+
+func (a *Agent) CancelInnerLoop(cause error) {
+	a.cancelInnerLoopMu.Lock()
+	defer a.cancelInnerLoopMu.Unlock()
+	if a.cancelInnerLoop != nil {
+		a.cancelInnerLoop(cause)
+	}
+}
+
+func (a *Agent) Loop(ctxOuter context.Context) {
+	for {
+		select {
+		case <-ctxOuter.Done():
+			return
+		default:
+			ctxInner, cancel := context.WithCancelCause(ctxOuter)
+			a.cancelInnerLoopMu.Lock()
+			// Set .cancelInnerLoop so the user can cancel whatever is happening
+			// inside InnerLoop(ctxInner) without canceling this outer Loop execution.
+			// This CancelInnerLoop func is intended be called from other goroutines,
+			// hence the mutex.
+			a.cancelInnerLoop = cancel
+			a.cancelInnerLoopMu.Unlock()
+			a.InnerLoop(ctxInner)
+			cancel(nil)
+		}
+	}
+}
+
+func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
+	if m.Timestamp.IsZero() {
+		m.Timestamp = time.Now()
+	}
+
+	// If this is an end-of-turn message, calculate the turn duration and add it to the message
+	if m.EndOfTurn && m.Type == AgentMessageType {
+		turnDuration := time.Since(a.startOfTurn)
+		m.TurnDuration = &turnDuration
+		slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
+	}
+
+	slog.InfoContext(ctx, "agent message", m.Attr())
+
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	m.Idx = len(a.history)
+	a.history = append(a.history, m)
+	a.outbox <- m
+
+	// Notify all listeners:
+	for _, ch := range a.listeners {
+		close(ch)
+	}
+	a.listeners = a.listeners[:0]
+}
+
+func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
+	var m []ant.Content
+	if block {
+		select {
+		case <-ctx.Done():
+			return m, ctx.Err()
+		case msg := <-a.inbox:
+			m = append(m, ant.Content{Type: "text", Text: msg})
+		}
+	}
+	for {
+		select {
+		case msg := <-a.inbox:
+			m = append(m, ant.Content{Type: "text", Text: msg})
+		default:
+			return m, nil
+		}
+	}
+}
+
+func (a *Agent) InnerLoop(ctx context.Context) {
+	// Reset the start of turn time
+	a.startOfTurn = time.Now()
+
+	// Wait for at least one message from the user.
+	msgs, err := a.GatherMessages(ctx, true)
+	if err != nil { // e.g. the context was canceled while blocking in GatherMessages
+		return
+	}
+	// We do this as we go, but let's also do it at the end of the turn
+	defer func() {
+		if _, err := a.handleGitCommits(ctx); err != nil {
+			// Just log the error, don't stop execution
+			slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
+		}
+	}()
+
+	userMessage := ant.Message{
+		Role:    "user",
+		Content: msgs,
+	}
+	// convo.SendMessage does the actual network call to send this to anthropic. This blocks until the response is ready.
+	// TODO: pass ctx to SendMessage, and figure out how to square that ctx with convo's own .Ctx.  Who owns the scope of this call?
+	resp, err := a.convo.SendMessage(userMessage)
+	if err != nil {
+		a.pushToOutbox(ctx, errorMessage(err))
+		return
+	}
+	for {
+		// TODO: here and below where we check the budget,
+		// we should review the UX: is it clear what happened?
+		// is it clear how to resume?
+		// should we let the user set a new budget?
+		if err := a.overBudget(ctx); err != nil {
+			return
+		}
+		if resp.StopReason != ant.StopReasonToolUse {
+			break
+		}
+		var results []ant.Content
+		cancelled := false
+		select {
+		case <-ctx.Done():
+			// Don't actually run any of the tools, but rather build a response
+			// for each tool_use message letting the LLM know that user canceled it.
+			results, err = a.convo.ToolResultCancelContents(resp)
+			if err != nil {
+				a.pushToOutbox(ctx, errorMessage(err))
+			}
+			cancelled = true
+		default:
+			ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
+			// fall-through, when the user has not canceled the inner loop:
+			results, err = a.convo.ToolResultContents(ctx, resp)
+			if ctx.Err() != nil { // e.g. the user canceled the operation
+				cancelled = true
+			} else if err != nil {
+				a.pushToOutbox(ctx, errorMessage(err))
+			}
+		}
+
+		// Check for git commits. Currently we do this here, after we collect
+		// tool results, since that's when we know commits could have happened.
+		// We could instead do this when the turn ends, but I think it makes sense
+		// to do this as we go.
+		newCommits, err := a.handleGitCommits(ctx)
+		if err != nil {
+			// Just log the error, don't stop execution
+			slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
+		}
+		var autoqualityMessages []string
+		if len(newCommits) == 1 {
+			formatted := a.codereview.Autoformat(ctx)
+			if len(formatted) > 0 {
+				msg := fmt.Sprintf(`
+I ran autoformatters and they updated these files:
+
+%s
+
+Please amend your latest git commit with these changes and then continue with what you were doing.`,
+					strings.Join(formatted, "\n"),
+				)[1:]
+				a.pushToOutbox(ctx, AgentMessage{
+					Type:      AutoMessageType,
+					Content:   msg,
+					Timestamp: time.Now(),
+				})
+				autoqualityMessages = append(autoqualityMessages, msg)
+			}
+		}
+
+		if err := a.overBudget(ctx); err != nil {
+			return
+		}
+
+		// Include, along with the tool results (which must go first for whatever reason),
+		// any messages that the user has sent along while the tool_use was executing concurrently.
+		msgs, err = a.GatherMessages(ctx, false)
+		if err != nil {
+			return
+		}
+		// Inject any auto-generated messages from quality checks.
+		for _, msg := range autoqualityMessages {
+			msgs = append(msgs, ant.Content{Type: "text", Text: msg})
+		}
+		if cancelled {
+			msgs = append(msgs, ant.Content{Type: "text", Text: cancelToolUseMessage})
+			// EndOfTurn is false here so that the client of this agent keeps processing
+			// messages from WaitForMessage() and gets the response from the LLM (usually
+			// something like "okay, I'll wait further instructions", but the user should
+			// be made aware of it regardless).
+			a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
+		} else if err := a.convo.OverBudget(); err != nil {
+			budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
+			msgs = append(msgs, ant.Content{Type: "text", Text: budgetMsg})
+			a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
+		}
+		results = append(results, msgs...)
+		resp, err = a.convo.SendMessage(ant.Message{
+			Role:    "user",
+			Content: results,
+		})
+		if err != nil {
+			a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
+			break
+		}
+		if cancelled {
+			return
+		}
+	}
+}
+
+func (a *Agent) overBudget(ctx context.Context) error {
+	if err := a.convo.OverBudget(); err != nil {
+		m := budgetMessage(err)
+		m.Content = m.Content + "\n\nBudget reset."
+		a.pushToOutbox(ctx, budgetMessage(err))
+		a.convo.ResetBudget(a.originalBudget)
+		return err
+	}
+	return nil
+}
+
+func collectTextContent(msg *ant.MessageResponse) string {
+	// Collect all text content
+	var allText strings.Builder
+	for _, content := range msg.Content {
+		if content.Type == "text" && content.Text != "" {
+			if allText.Len() > 0 {
+				allText.WriteString("\n\n")
+			}
+			allText.WriteString(content.Text)
+		}
+	}
+	return allText.String()
+}
+
+func (a *Agent) TotalUsage() ant.CumulativeUsage {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	return a.convo.CumulativeUsage()
+}
+
+// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
+func (a *Agent) WaitForMessageCount(ctx context.Context, greaterThan int) {
+	for a.MessageCount() <= greaterThan {
+		a.mu.Lock()
+		ch := make(chan struct{})
+		// Deletion happens when we notify.
+		a.listeners = append(a.listeners, ch)
+		a.mu.Unlock()
+
+		select {
+		case <-ctx.Done():
+			return
+		case <-ch:
+			continue
+		}
+	}
+}
+
+// Diff returns a unified diff of changes made since the agent was instantiated.
+func (a *Agent) Diff(commit *string) (string, error) {
+	if a.initialCommit == "" {
+		return "", fmt.Errorf("no initial commit reference available")
+	}
+
+	// Find the repository root
+	ctx := context.Background()
+
+	// If a specific commit hash is provided, show just that commit's changes
+	if commit != nil && *commit != "" {
+		// Validate that the commit looks like a valid git SHA
+		if !isValidGitSHA(*commit) {
+			return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
+		}
+
+		// Get the diff for just this commit
+		cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
+		cmd.Dir = a.repoRoot
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
+		}
+		return string(output), nil
+	}
+
+	// Otherwise, get the diff between the initial commit and the current state using exec.Command
+	cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
+	cmd.Dir = a.repoRoot
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
+	}
+
+	return string(output), nil
+}
+
+// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
+func (a *Agent) InitialCommit() string {
+	return a.initialCommit
+}
+
+// handleGitCommits() highlights new commits to the user. When running
+// under docker, new HEADs are pushed to a branch according to the title.
+func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
+	if a.repoRoot == "" {
+		return nil, nil
+	}
+
+	head, err := resolveRef(ctx, a.repoRoot, "HEAD")
+	if err != nil {
+		return nil, err
+	}
+	if head == a.lastHEAD {
+		return nil, nil // nothing to do
+	}
+	defer func() {
+		a.lastHEAD = head
+	}()
+
+	// Get new commits. Because it's possible that the agent does rebases, fixups, and
+	// so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
+	// to the last 100 commits.
+	var commits []*GitCommit
+
+	// Get commits since the initial commit
+	// Format: <hash>\0<subject>\0<body>\0
+	// This uses NULL bytes as separators to avoid issues with newlines in commit messages
+	// Limit to 100 commits to avoid overwhelming the user
+	cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
+	cmd.Dir = a.repoRoot
+	output, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get git log: %w", err)
+	}
+
+	// Parse git log output and filter out already seen commits
+	parsedCommits := parseGitLog(string(output))
+
+	var headCommit *GitCommit
+
+	// Filter out commits we've already seen
+	for _, commit := range parsedCommits {
+		if commit.Hash == head {
+			headCommit = &commit
+		}
+
+		// Skip if we've seen this commit before. If our head has changed, always include that.
+		if a.seenCommits[commit.Hash] && commit.Hash != head {
+			continue
+		}
+
+		// Mark this commit as seen
+		a.seenCommits[commit.Hash] = true
+
+		// Add to our list of new commits
+		commits = append(commits, &commit)
+	}
+
+	if a.gitRemoteAddr != "" {
+		if headCommit == nil {
+			// I think this can only happen if we have a bug or if there's a race.
+			headCommit = &GitCommit{}
+			headCommit.Hash = head
+			headCommit.Subject = "unknown"
+			commits = append(commits, headCommit)
+		}
+
+		cleanTitle := titleToBranch(a.title)
+		if cleanTitle == "" {
+			cleanTitle = a.config.SessionID
+		}
+		branch := "sketch/" + cleanTitle
+
+		// TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
+		// if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
+		// then use push with lease to replace.
+		cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
+		cmd.Dir = a.workingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
+		} else {
+			headCommit.PushedBranch = branch
+		}
+	}
+
+	// If we found new commits, create a message
+	if len(commits) > 0 {
+		msg := AgentMessage{
+			Type:      CommitMessageType,
+			Timestamp: time.Now(),
+			Commits:   commits,
+		}
+		a.pushToOutbox(ctx, msg)
+	}
+	return commits, nil
+}
+
+func titleToBranch(s string) string {
+	// Convert to lowercase
+	s = strings.ToLower(s)
+
+	// Replace spaces with hyphens
+	s = strings.ReplaceAll(s, " ", "-")
+
+	// Remove any character that isn't a-z or hyphen
+	var result strings.Builder
+	for _, r := range s {
+		if (r >= 'a' && r <= 'z') || r == '-' {
+			result.WriteRune(r)
+		}
+	}
+	return result.String()
+}
+
+// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
+// and returns an array of GitCommit structs.
+func parseGitLog(output string) []GitCommit {
+	var commits []GitCommit
+
+	// No output means no commits
+	if len(output) == 0 {
+		return commits
+	}
+
+	// Split by NULL byte
+	parts := strings.Split(output, "\x00")
+
+	// Process in triplets (hash, subject, body)
+	for i := 0; i < len(parts); i++ {
+		// Skip empty parts
+		if parts[i] == "" {
+			continue
+		}
+
+		// This should be a hash
+		hash := strings.TrimSpace(parts[i])
+
+		// Make sure we have at least a subject part available
+		if i+1 >= len(parts) {
+			break // No more parts available
+		}
+
+		// Get the subject
+		subject := strings.TrimSpace(parts[i+1])
+
+		// Get the body if available
+		body := ""
+		if i+2 < len(parts) {
+			body = strings.TrimSpace(parts[i+2])
+		}
+
+		// Skip to the next triplet
+		i += 2
+
+		commits = append(commits, GitCommit{
+			Hash:    hash,
+			Subject: subject,
+			Body:    body,
+		})
+	}
+
+	return commits
+}
+
+func repoRoot(ctx context.Context, dir string) (string, error) {
+	cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
+	stderr := new(strings.Builder)
+	cmd.Stderr = stderr
+	cmd.Dir = dir
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+func resolveRef(ctx context.Context, dir, refName string) (string, error) {
+	cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
+	stderr := new(strings.Builder)
+	cmd.Stderr = stderr
+	cmd.Dir = dir
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
+	}
+	// TODO: validate that out is valid hex
+	return strings.TrimSpace(string(out)), nil
+}
+
+// isValidGitSHA validates if a string looks like a valid git SHA hash.
+// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
+func isValidGitSHA(sha string) bool {
+	// Git SHA must be a hexadecimal string with at least 4 characters
+	if len(sha) < 4 || len(sha) > 40 {
+		return false
+	}
+
+	// Check if the string only contains hexadecimal characters
+	for _, char := range sha {
+		if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
+			return false
+		}
+	}
+
+	return true
+}