Restart conversation support.

The idea here is to let the user restart the conversation, possibly with
a better prompt. This is a common manual workflow, and I'd like to make
it easier.

I hand wrote the agent.go stuff, but Sketch wrote the rest.

Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/loop/agent.go b/loop/agent.go
index 497f048..b698d22 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -92,6 +92,15 @@
 	OutsideHostname() string
 	OutsideWorkingDir() string
 	GitOrigin() string
+
+	// RestartConversation resets the conversation history
+	RestartConversation(ctx context.Context, rev string, initialPrompt string) error
+	// SuggestReprompt suggests a re-prompt based on the current conversation.
+	SuggestReprompt(ctx context.Context) (string, error)
+	// IsInContainer returns true if the agent is running in a container
+	IsInContainer() bool
+	// FirstMessageIndex returns the index of the first message in the current conversation
+	FirstMessageIndex() int
 }
 
 type CodingAgentMessageType string
@@ -248,26 +257,29 @@
 	OverBudget() error
 	SendMessage(message ant.Message) (*ant.MessageResponse, error)
 	SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+	GetID() string
 	ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
 	ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
 	CancelToolUse(toolUseID string, cause error) error
+	SubConvoWithHistory() *ant.Convo
 }
 
 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
-	branchName     string
-	codereview     *claudetool.CodeReviewer
+	convo             ConvoInterface
+	config            AgentConfig // config for this agent
+	workingDir        string
+	repoRoot          string // workingDir may be a subdir of repoRoot
+	url               string
+	firstMessageIndex int           // index of the first message in the current conversation
+	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
+	branchName        string
+	codereview        *claudetool.CodeReviewer
 	// Outside information
 	outsideHostname   string
 	outsideOS         string
@@ -379,6 +391,16 @@
 	return a.gitOrigin
 }
 
+func (a *Agent) IsInContainer() bool {
+	return a.config.InDocker
+}
+
+func (a *Agent) FirstMessageIndex() int {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	return a.firstMessageIndex
+}
+
 // SetTitleBranch sets the title and branch name of the conversation.
 func (a *Agent) SetTitleBranch(title, branchName string) {
 	a.mu.Lock()
@@ -531,6 +553,7 @@
 	SessionID        string
 	ClientGOOS       string
 	ClientGOARCH     string
+	InDocker         bool
 	UseAnthropicEdit bool
 	// Outside information
 	OutsideHostname   string
@@ -1382,3 +1405,82 @@
 	}
 	return strings.TrimSpace(string(out))
 }
+
+func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
+	cmd := exec.CommandContext(ctx, "git", "stash")
+	cmd.Dir = workingDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git stash: %s: %v", out, err)
+	}
+	cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
+	cmd.Dir = workingDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git fetch: %s: %w", out, err)
+	}
+	cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
+	cmd.Dir = workingDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
+	}
+	a.lastHEAD = revision
+	a.initialCommit = revision
+	return nil
+}
+
+func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
+	a.mu.Lock()
+	a.title = ""
+	a.firstMessageIndex = len(a.history)
+	a.convo = a.initConvo()
+	gitReset := func() error {
+		if a.config.InDocker && rev != "" {
+			err := a.initGitRevision(ctx, a.workingDir, rev)
+			if err != nil {
+				return err
+			}
+		} else if !a.config.InDocker && rev != "" {
+			return fmt.Errorf("Not resetting git repo when working outside of a container.")
+		}
+		return nil
+	}
+	err := gitReset()
+	a.mu.Unlock()
+	if err != nil {
+		a.pushToOutbox(a.config.Context, errorMessage(err))
+	}
+
+	a.pushToOutbox(a.config.Context, AgentMessage{
+		Type: AgentMessageType, Content: "Conversation restarted.",
+	})
+	if initialPrompt != "" {
+		a.UserMessage(ctx, initialPrompt)
+	}
+	return nil
+}
+
+func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
+	msg := `The user has requested a suggestion for a re-prompt.
+
+	Given the current conversation thus far, suggest a re-prompt that would
+	capture the instructions and feedback so far, as well as any
+	research or other information that would be helpful in implementing
+	the task.
+
+	Reply with ONLY the reprompt text.
+	`
+	userMessage := ant.Message{
+		Role:    "user",
+		Content: []ant.Content{{Type: "text", Text: msg}},
+	}
+	// By doing this in a subconversation, the agent doesn't call tools (because
+	// there aren't any), and there's not a concurrency risk with on-going other
+	// outstanding conversations.
+	convo := a.convo.SubConvoWithHistory()
+	resp, err := convo.SendMessage(userMessage)
+	if err != nil {
+		a.pushToOutbox(ctx, errorMessage(err))
+		return "", err
+	}
+	textContent := collectTextContent(resp)
+	return textContent, nil
+}
diff --git a/loop/agent_test.go b/loop/agent_test.go
index 61b057c..bde0d20 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -267,6 +267,8 @@
 	cumulativeUsageFunc          func() ant.CumulativeUsage
 	resetBudgetFunc              func(ant.Budget)
 	overBudgetFunc               func() error
+	getIDFunc                    func() string
+	subConvoWithHistoryFunc      func() *ant.Convo
 }
 
 func (m *MockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
@@ -324,6 +326,20 @@
 	return nil
 }
 
+func (m *MockConvoInterface) GetID() string {
+	if m.getIDFunc != nil {
+		return m.getIDFunc()
+	}
+	return "mock-convo-id"
+}
+
+func (m *MockConvoInterface) SubConvoWithHistory() *ant.Convo {
+	if m.subConvoWithHistoryFunc != nil {
+		return m.subConvoWithHistoryFunc()
+	}
+	return nil
+}
+
 // TestAgentProcessTurnWithNilResponseNilError tests the scenario where Agent.processTurn receives
 // a nil value for initialResp and nil error from processUserMessage.
 // This test verifies that the implementation properly handles this edge case.
diff --git a/loop/mocks.go b/loop/mocks.go
index 264c6bc..7e05070 100644
--- a/loop/mocks.go
+++ b/loop/mocks.go
@@ -192,6 +192,16 @@
 	return nil
 }
 
+func (m *MockConvo) GetID() string {
+	m.recordCall("GetID")
+	return "mock-conversation-id"
+}
+
+func (m *MockConvo) SubConvoWithHistory() *ant.Convo {
+	m.recordCall("SubConvoWithHistory")
+	return nil
+}
+
 func (m *MockConvo) ResetBudget(_ ant.Budget) {
 	m.recordCall("ResetBudget")
 }
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index c1254e2..c19f806 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -64,6 +64,8 @@
 	SessionID            string               `json:"session_id"`
 	SSHAvailable         bool                 `json:"ssh_available"`
 	SSHError             string               `json:"ssh_error,omitempty"`
+	InContainer          bool                 `json:"in_container"`
+	FirstMessageIndex    int                  `json:"first_message_index"`
 
 	OutsideHostname   string `json:"outside_hostname,omitempty"`
 	InsideHostname    string `json:"inside_hostname,omitempty"`
@@ -388,6 +390,8 @@
 			SessionID:            agent.SessionID(),
 			SSHAvailable:         s.sshAvailable,
 			SSHError:             s.sshError,
+			InContainer:          agent.IsInContainer(),
+			FirstMessageIndex:    agent.FirstMessageIndex(),
 		}
 
 		// Create a JSON encoder with indentation for pretty-printing
@@ -451,6 +455,94 @@
 		w.Write(data)
 	})
 
+	// Handler for POST /restart - restarts the conversation
+	s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		// Parse the request body
+		var requestBody struct {
+			Revision      string `json:"revision"`
+			InitialPrompt string `json:"initial_prompt"`
+		}
+
+		decoder := json.NewDecoder(r.Body)
+		if err := decoder.Decode(&requestBody); err != nil {
+			http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+		defer r.Body.Close()
+
+		// Call the restart method
+		err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
+		if err != nil {
+			http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// Return success response
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
+	})
+
+	// Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+	// Handler for /commit-description - returns the description of a git commit
+	s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		// Get the revision parameter
+		revision := r.URL.Query().Get("revision")
+		if revision == "" {
+			http.Error(w, "Missing revision parameter", http.StatusBadRequest)
+			return
+		}
+
+		// Run git command to get commit description
+		cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
+		// Use the working directory from the agent
+		cmd.Dir = s.agent.WorkingDir()
+
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// Prepare the response
+		resp := map[string]string{
+			"description": strings.TrimSpace(string(output)),
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		if err := json.NewEncoder(w).Encode(resp); err != nil {
+			slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
+		}
+	})
+
+	// Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+	s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		// Call the suggest reprompt method
+		suggestedPrompt, err := agent.SuggestReprompt(r.Context())
+		if err != nil {
+			http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// Return success response
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
+	})
+
 	// Handler for POST /chat
 	s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
 		if r.Method != http.MethodPost {
@@ -774,9 +866,11 @@
 	mux := http.NewServeMux()
 	mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		// TODO: pid is not as useful as "outside pid"
 		fmt.Fprintf(w, `<!doctype html>
 			<html><head><title>sketch debug</title></head><body>
 			<h1>sketch debug</h1>
+			pid %d
 			<ul>
 					<li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
 					<li><a href="/debug/pprof/profile">pprof/profile</a></li>
@@ -787,7 +881,7 @@
 			</ul>
 			</body>
 			</html>
-			`)
+			`, os.Getpid())
 	})
 	mux.HandleFunc("GET /debug/pprof/", pprof.Index)
 	mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)