codereview: add 1-minute timeout parameter with context propagation

Users and I have seen codereview hanging. I think giving it one
minute is better than nothing.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s32c0166e97ef0effk
diff --git a/claudetool/codereview/differential.go b/claudetool/codereview/differential.go
index 25defa4..d13cd9e 100644
--- a/claudetool/codereview/differential.go
+++ b/claudetool/codereview/differential.go
@@ -31,26 +31,58 @@
 		Name:        "codereview",
 		Description: `Run an automated code review before presenting git commits to the user. Call if/when you've completed your current work and are ready for user feedback.`,
 		// If you modify this, update the termui template for prettier rendering.
-		InputSchema: llm.EmptySchema(),
-		Run:         r.Run,
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 1m)",
+					"default": "1m"
+				}
+			}
+		}`),
+		Run: r.Run,
 	}
 	return spec
 }
 
 func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+	// Parse input to get timeout
+	var input struct {
+		Timeout string `json:"timeout"`
+	}
+	if len(m) > 0 {
+		if err := json.Unmarshal(m, &input); err != nil {
+			return nil, fmt.Errorf("failed to parse input: %w", err)
+		}
+	}
+	if input.Timeout == "" {
+		input.Timeout = "1m" // default timeout
+	}
+
+	// Parse timeout duration
+	timeout, err := time.ParseDuration(input.Timeout)
+	if err != nil {
+		return nil, fmt.Errorf("invalid timeout duration %q: %w", input.Timeout, err)
+	}
+
+	// Create timeout context
+	timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
 	// NOTE: If you add or modify error messages here, update the corresponding UI parsing in:
 	// webui/src/web-components/sketch-tool-card.ts (SketchToolCardCodeReview.getStatusIcon)
-	if err := r.RequireNormalGitState(ctx); err != nil {
+	if err := r.RequireNormalGitState(timeoutCtx); err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
 		return nil, err
 	}
-	if err := r.RequireNoUncommittedChanges(ctx); err != nil {
+	if err := r.RequireNoUncommittedChanges(timeoutCtx); err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
 		return nil, err
 	}
 
 	// Check that the current commit is not the initial commit
-	currentCommit, err := r.CurrentCommit(ctx)
+	currentCommit, err := r.CurrentCommit(timeoutCtx)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
 		return nil, err
@@ -64,7 +96,7 @@
 	// This should help avoid the model getting blocked by a broken code review tool.
 	r.reviewed = append(r.reviewed, currentCommit)
 
-	changedFiles, err := r.changedFiles(ctx, r.sketchBaseRef, currentCommit)
+	changedFiles, err := r.changedFiles(timeoutCtx, r.sketchBaseRef, currentCommit)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
 		return nil, err
@@ -75,7 +107,7 @@
 	// The packages in the initial commit may be different.
 	// Good enough for now.
 	// TODO: do better
-	allPkgs, err := r.packagesForFiles(ctx, changedFiles)
+	allPkgs, err := r.packagesForFiles(timeoutCtx, changedFiles)
 	if err != nil {
 		// TODO: log and skip to stuff that doesn't require packages
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
@@ -87,7 +119,7 @@
 	var infoMessages []string  // info the model should consider
 
 	// Run 'go generate' early, so that it can potentially fix tests that would otherwise fail.
-	generateChanges, err := r.runGenerate(ctx, allPkgList)
+	generateChanges, err := r.runGenerate(timeoutCtx, allPkgList)
 	if err != nil {
 		errorMessages = append(errorMessages, err.Error())
 	}
@@ -105,7 +137,7 @@
 	// Find potentially related files that should also be considered
 	// TODO: add some caching here, since this depends only on the initial commit and the changed files, not the details of the changes
 	// TODO: arrange for this to run even in non-Go repos!
-	relatedFiles, err := r.findRelatedFiles(ctx, changedFiles)
+	relatedFiles, err := r.findRelatedFiles(timeoutCtx, changedFiles)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to find related files", "err", err)
 	} else {
@@ -115,7 +147,7 @@
 		}
 	}
 
-	testMsg, err := r.checkTests(ctx, allPkgList)
+	testMsg, err := r.checkTests(timeoutCtx, allPkgList)
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
 		return nil, err
@@ -124,7 +156,7 @@
 		errorMessages = append(errorMessages, testMsg)
 	}
 
-	goplsMsg, err := r.checkGopls(ctx, changedFiles) // includes vet checks
+	goplsMsg, err := r.checkGopls(timeoutCtx, changedFiles) // includes vet checks
 	if err != nil {
 		slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
 		return nil, err
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index 68cb57a..af647a0 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,9 +1,9 @@
 httprr trace v1
-20362 2598
+20508 2545
 POST https://api.anthropic.com/v1/messages HTTP/1.1

 Host: api.anthropic.com

 User-Agent: Go-http-client/1.1

-Content-Length: 20164

+Content-Length: 20310

 Anthropic-Version: 2023-06-01

 Content-Type: application/json

 

@@ -289,7 +289,13 @@
    "description": "Run an automated code review before presenting git commits to the user. Call if/when you've completed your current work and are ready for user feedback.",
    "input_schema": {
     "type": "object",
-    "properties": {}
+    "properties": {
+     "timeout": {
+      "type": "string",
+      "description": "Timeout as a Go duration string (default: 1m)",
+      "default": "1m"
+     }
+    }
    }
   },
   {
@@ -597,24 +603,24 @@
 Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b

 Anthropic-Ratelimit-Input-Tokens-Limit: 200000

 Anthropic-Ratelimit-Input-Tokens-Remaining: 200000

-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-06-12T01:30:49Z

+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-06-18T16:59:33Z

 Anthropic-Ratelimit-Output-Tokens-Limit: 80000

 Anthropic-Ratelimit-Output-Tokens-Remaining: 80000

-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-06-12T01:30:57Z

+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-06-18T16:59:40Z

 Anthropic-Ratelimit-Requests-Limit: 4000

 Anthropic-Ratelimit-Requests-Remaining: 3999

-Anthropic-Ratelimit-Requests-Reset: 2025-06-12T01:30:48Z

+Anthropic-Ratelimit-Requests-Reset: 2025-06-18T16:59:31Z

 Anthropic-Ratelimit-Tokens-Limit: 280000

 Anthropic-Ratelimit-Tokens-Remaining: 280000

-Anthropic-Ratelimit-Tokens-Reset: 2025-06-12T01:30:49Z

+Anthropic-Ratelimit-Tokens-Reset: 2025-06-18T16:59:33Z

 Cf-Cache-Status: DYNAMIC

-Cf-Ray: 94e595c2cf2615c2-SJC

+Cf-Ray: 951c56742fd09429-SJC

 Content-Type: application/json

-Date: Thu, 12 Jun 2025 01:30:57 GMT

-Request-Id: req_011CQ3TmsfarEWcq1a3S6vAH

+Date: Wed, 18 Jun 2025 16:59:40 GMT

+Request-Id: req_011CQG3T1Bzoc5bvXLivA3rr

 Server: cloudflare

 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

 Via: 1.1 google

 X-Robots-Tag: none

 

-{"id":"msg_012nUH4GVNxkfLo2t455oSoi","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Management:**\n- `bash` - Execute shell commands\n- `keyword_search` - Search for files/code with keywords\n- `patch` - Edit files with precise text operations\n- `codereview` - Run automated code review\n\n**Task Management:**\n- `todo_read` - Read current todo list\n- `todo_write` - Create/update task lists\n- `think` - Take notes and form plans\n\n**Git & Project:**\n- `commit-message-style` - Get git commit message guidance\n- `done` - Mark work complete with checklist verification\n- `set-slug` - Set conversation identifier\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n\n**Utilities:**\n- `read_image` - Read and encode image files\n- `about_sketch` - Get help with Sketch functionality\n- `multiplechoice` - Present multiple choice questions"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4670,"cache_read_input_tokens":0,"output_tokens":342,"service_tier":"standard"}}
\ No newline at end of file
+{"id":"msg_01NMyFnihvJvCUBbS2rVQk68","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Management:**\n- `bash` - Execute shell commands\n- `keyword_search` - Search files by keywords/concepts\n- `patch` - Modify files with precise text edits\n\n**Planning & Organization:**\n- `think` - Record thoughts and plans\n- `todo_read` / `todo_write` - Manage task lists\n- `set-slug` - Set conversation identifier\n\n**Git & Code Quality:**\n- `commit-message-style` - Get commit message guidance\n- `codereview` - Run automated code review\n- `done` - Complete work with verification checklist\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_recent_console_logs` / `browser_clear_console_logs` - Manage console logs\n\n**Utilities:**\n- `read_image` - Read and encode image files\n- `about_sketch` - Get help with Sketch functionality\n- `multiplechoice` - Present multiple choice questions"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4703,"cache_read_input_tokens":0,"output_tokens":331,"service_tier":"standard"}}
\ No newline at end of file