Quellcode durchsuchen

feat: Always run commands

Catriel Müller vor 3 Monaten
Ursprung
Commit
f71fbc4d4f

+ 47 - 0
cli/README.md

@@ -136,6 +136,53 @@ Autonomous mode respects your auto-approval configuration. Edit your config file
 - `retry`: Auto-approve API retry requests
 - `todo`: Auto-approve todo list updates
 
+#### Command Approval Patterns
+
+The `execute.allowed` and `execute.denied` lists support hierarchical pattern matching:
+
+- **Base command**: `"git"` matches any git command (e.g., `git status`, `git commit`, `git push`)
+- **Command + subcommand**: `"git status"` matches any git status command (e.g., `git status --short`, `git status -v`)
+- **Full command**: `"git status --short"` only matches exactly `git status --short`
+
+**Example:**
+
+```json
+{
+	"execute": {
+		"enabled": true,
+		"allowed": [
+			"npm", // Allows all npm commands
+			"git status", // Allows all git status commands
+			"ls -la" // Only allows exactly "ls -la"
+		],
+		"denied": [
+			"git push --force" // Denies this specific command even if "git" is allowed
+		]
+	}
+}
+```
+
+#### Interactive Command Approval
+
+When running in interactive mode, command approval requests now show hierarchical options:
+
+```
+[!] Action Required:
+> ✓ Run Command (y)
+  ✓ Always run git (1)
+  ✓ Always run git status (2)
+  ✓ Always run git status --short --branch (3)
+  ✗ Reject (n)
+```
+
+Selecting an "Always run" option will:
+
+1. Approve and execute the current command
+2. Add the pattern to your `execute.allowed` list in the config
+3. Auto-approve matching commands in the future
+
+This allows you to progressively build your auto-approval rules without manually editing the config file.
+
 #### Autonomous mode Follow-up Questions
 
 In Autonomous mode, when the AI asks a follow-up question, it receives this response:

+ 100 - 0
cli/src/services/__tests__/approvalDecision.test.ts

@@ -297,6 +297,106 @@ describe("approvalDecision", () => {
 				const decision = getApprovalDecision(message, config, false)
 				expect(decision.action).toBe("auto-approve")
 			})
+
+			describe("hierarchical command matching", () => {
+				it("should match base command pattern", () => {
+					const message = createMessage("command", JSON.stringify({ command: "git status --short" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("auto-approve")
+				})
+
+				it("should match command with subcommand pattern", () => {
+					const message = createMessage("command", JSON.stringify({ command: "git status --short --branch" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git status"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("auto-approve")
+				})
+
+				it("should match exact command pattern", () => {
+					const message = createMessage("command", JSON.stringify({ command: "git status --short" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git status --short"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("auto-approve")
+				})
+
+				it("should not match partial word patterns", () => {
+					const message = createMessage("command", JSON.stringify({ command: "gitignore" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("manual")
+				})
+
+				it("should handle commands with multiple spaces", () => {
+					const message = createMessage("command", JSON.stringify({ command: "npm  install  --save" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["npm"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("auto-approve")
+				})
+
+				it("should handle commands with tabs", () => {
+					const message = createMessage("command", JSON.stringify({ command: "git\tstatus" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git"],
+							denied: [],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("auto-approve")
+				})
+
+				it("should respect denied patterns over allowed patterns", () => {
+					const message = createMessage("command", JSON.stringify({ command: "git push --force" }))
+					const config = {
+						...createBaseConfig(),
+						execute: {
+							enabled: true,
+							allowed: ["git"],
+							denied: ["git push --force"],
+						},
+					}
+					const decision = getApprovalDecision(message, config, false)
+					expect(decision.action).toBe("manual")
+				})
+			})
 		})
 
 		describe("followup questions", () => {

+ 38 - 11
cli/src/services/approvalDecision.ts

@@ -27,16 +27,34 @@ export interface ApprovalDecision {
 
 /**
  * Helper function to check if a command matches allowed/denied patterns
+ * Supports hierarchical matching:
+ * - "git" matches "git status", "git commit", etc.
+ * - "git status" matches "git status --short", "git status -v", etc.
+ * - Exact match: "git status --short" only matches "git status --short"
  */
 function matchesCommandPattern(command: string, patterns: string[]): boolean {
 	if (patterns.length === 0) return false
 
+	const normalizedCommand = command.trim()
+
 	return patterns.some((pattern) => {
-		// Simple pattern matching - can be enhanced with regex if needed
-		if (pattern === "*") return true
-		if (pattern === command) return true
-		// Check if command starts with pattern (for partial matches like "npm")
-		if (command.startsWith(pattern)) return true
+		const normalizedPattern = pattern.trim()
+
+		// Wildcard matches everything
+		if (normalizedPattern === "*") return true
+
+		// Exact match
+		if (normalizedPattern === normalizedCommand) return true
+
+		// Hierarchical match: pattern is a prefix of the command
+		// Must match word boundaries to avoid false positives
+		// e.g., "git" matches "git status" but not "gitignore"
+		if (normalizedCommand.startsWith(normalizedPattern)) {
+			// Check if it's followed by whitespace or end of string
+			const nextChar = normalizedCommand[normalizedPattern.length]
+			return nextChar === undefined || nextChar === " " || nextChar === "\t"
+		}
+
 		return false
 	})
 }
@@ -161,25 +179,27 @@ function getCommandApprovalDecision(
 		return isCIMode ? { action: "auto-reject", message: CI_MODE_MESSAGES.AUTO_REJECTED } : { action: "manual" }
 	}
 
-	// Parse command from message - it's stored as JSON with a "command" field
+	// Parse command from message
+	// It can be either JSON with a "command" field or plain text
 	let command = ""
 	try {
 		const commandData = JSON.parse(message.text || "{}")
-		command = commandData.command || message.text || ""
+		command = commandData.command || ""
 	} catch {
-		// If parsing fails, use text directly
+		// If parsing fails, use text directly as the command
 		command = message.text || ""
 	}
 
 	const allowedCommands = config.execute?.allowed ?? []
 	const deniedCommands = config.execute?.denied ?? []
 
-	logs.debug("Checking command approval", "approvalDecision", {
+	logs.info("Checking command approval", "approvalDecision", {
 		command,
 		rawText: message.text,
 		allowedCommands,
 		deniedCommands,
 		executeEnabled: config.execute?.enabled,
+		configExecute: config.execute,
 	})
 
 	// Check denied list first (takes precedence)
@@ -196,11 +216,18 @@ function getCommandApprovalDecision(
 
 	// Check if command matches allowed patterns
 	if (matchesCommandPattern(command, allowedCommands)) {
-		logs.debug("Command matches allowed pattern - auto-approving", "approvalDecision", { command })
+		logs.info("Command matches allowed pattern - auto-approving", "approvalDecision", {
+			command,
+			matchedAgainst: allowedCommands,
+		})
 		return { action: "auto-approve" }
 	}
 
-	logs.debug("Command does not match any allowed pattern", "approvalDecision", { command, allowedCommands })
+	logs.info("Command does not match any allowed pattern", "approvalDecision", {
+		command,
+		allowedCommands,
+		deniedCommands,
+	})
 	return isCIMode ? { action: "auto-reject", message: CI_MODE_MESSAGES.AUTO_REJECTED } : { action: "manual" }
 }
 

+ 0 - 233
cli/src/state/atoms/__tests__/approval-race-conditions.test.ts

@@ -1,233 +0,0 @@
-/**
- * Tests for approval system race condition fixes
- *
- * These tests verify that the atomic state operations prevent:
- * - Duplicate approval attempts
- * - Stale closure issues
- * - UI not hiding after approval/rejection
- * - Synchronization issues between components
- */
-
-import { describe, it, expect, beforeEach } from "vitest"
-import { createStore } from "jotai"
-import {
-	pendingApprovalAtom,
-	approvalProcessingAtom,
-	setPendingApprovalAtom,
-	clearPendingApprovalAtom,
-	startApprovalProcessingAtom,
-	completeApprovalProcessingAtom,
-	isApprovalPendingAtom,
-} from "../approval.js"
-import type { ExtensionChatMessage } from "../../../types/messages.js"
-
-describe("Approval Race Condition Fixes", () => {
-	let store: ReturnType<typeof createStore>
-
-	beforeEach(() => {
-		store = createStore()
-	})
-
-	const createMockMessage = (ts: number, isAnswered = false): ExtensionChatMessage => ({
-		ts,
-		type: "ask",
-		ask: "tool",
-		text: JSON.stringify({ tool: "listFilesTopLevel", path: "." }),
-		partial: false,
-		isAnswered,
-	})
-
-	describe("Atomic State Operations", () => {
-		it("should prevent setting pending approval while processing", () => {
-			const message1 = createMockMessage(1000)
-			const message2 = createMockMessage(2000)
-
-			// Set first message as pending
-			store.set(setPendingApprovalAtom, message1)
-			expect(store.get(pendingApprovalAtom)).toBe(message1)
-
-			// Start processing
-			const started = store.set(startApprovalProcessingAtom, "approve")
-			expect(started).toBe(true)
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(true)
-
-			// Try to set second message while processing - should be ignored
-			store.set(setPendingApprovalAtom, message2)
-			expect(store.get(pendingApprovalAtom)).toBe(message1) // Still first message
-		})
-
-		it("should prevent duplicate approval processing", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-
-			// Start processing first time
-			const started1 = store.set(startApprovalProcessingAtom, "approve")
-			expect(started1).toBe(true)
-
-			// Try to start processing again - should fail
-			const started2 = store.set(startApprovalProcessingAtom, "approve")
-			expect(started2).toBe(false)
-
-			const processing = store.get(approvalProcessingAtom)
-			expect(processing.isProcessing).toBe(true)
-			expect(processing.processingTs).toBe(1000)
-		})
-
-		it("should clear both pending and processing state atomically", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "approve")
-
-			expect(store.get(pendingApprovalAtom)).toBe(message)
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(true)
-
-			// Complete processing - should clear both
-			store.set(completeApprovalProcessingAtom)
-
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(false)
-		})
-	})
-
-	describe("UI Visibility", () => {
-		it("should hide approval UI while processing", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			expect(store.get(isApprovalPendingAtom)).toBe(true)
-
-			// Start processing
-			store.set(startApprovalProcessingAtom, "approve")
-
-			// UI should be hidden while processing
-			expect(store.get(isApprovalPendingAtom)).toBe(false)
-		})
-
-		it("should hide approval UI after completion", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "approve")
-			store.set(completeApprovalProcessingAtom)
-
-			// UI should be hidden after completion
-			expect(store.get(isApprovalPendingAtom)).toBe(false)
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-		})
-	})
-
-	describe("Message State Handling", () => {
-		it("should not set pending approval for answered messages", () => {
-			const message = createMockMessage(1000, true)
-
-			store.set(setPendingApprovalAtom, message)
-
-			// Should not set pending approval for answered message
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-		})
-
-		it("should clear pending approval when message is answered", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			expect(store.get(pendingApprovalAtom)).toBe(message)
-
-			// Clear when answered
-			store.set(clearPendingApprovalAtom)
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-		})
-
-		it("should handle multiple messages sequentially", () => {
-			const message1 = createMockMessage(1000)
-			const message2 = createMockMessage(2000)
-
-			// Process first message
-			store.set(setPendingApprovalAtom, message1)
-			store.set(startApprovalProcessingAtom, "approve")
-			store.set(completeApprovalProcessingAtom)
-
-			// Now process second message
-			store.set(setPendingApprovalAtom, message2)
-			expect(store.get(pendingApprovalAtom)).toBe(message2)
-
-			const started = store.set(startApprovalProcessingAtom, "approve")
-			expect(started).toBe(true)
-		})
-	})
-
-	describe("Processing State Tracking", () => {
-		it("should track processing operation type", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "approve")
-
-			const processing = store.get(approvalProcessingAtom)
-			expect(processing.operation).toBe("approve")
-		})
-
-		it("should track processing timestamp", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "reject")
-
-			const processing = store.get(approvalProcessingAtom)
-			expect(processing.processingTs).toBe(1000)
-		})
-
-		it("should clear processing state on completion", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "approve")
-			store.set(completeApprovalProcessingAtom)
-
-			const processing = store.get(approvalProcessingAtom)
-			expect(processing.isProcessing).toBe(false)
-			expect(processing.processingTs).toBeUndefined()
-			expect(processing.operation).toBeUndefined()
-		})
-	})
-
-	describe("Edge Cases", () => {
-		it("should handle clearing when no pending approval exists", () => {
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-
-			// Should not throw
-			store.set(clearPendingApprovalAtom)
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-		})
-
-		it("should handle starting processing when no pending approval", () => {
-			expect(store.get(pendingApprovalAtom)).toBeNull()
-
-			const started = store.set(startApprovalProcessingAtom, "approve")
-			expect(started).toBe(false)
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(false)
-		})
-
-		it("should handle completing processing when not processing", () => {
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(false)
-
-			// Should not throw
-			store.set(completeApprovalProcessingAtom)
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(false)
-		})
-
-		it("should clear processing state when clearing pending approval with matching timestamp", () => {
-			const message = createMockMessage(1000)
-
-			store.set(setPendingApprovalAtom, message)
-			store.set(startApprovalProcessingAtom, "approve")
-
-			// Clear pending approval
-			store.set(clearPendingApprovalAtom)
-
-			// Processing state should also be cleared since timestamps match
-			expect(store.get(approvalProcessingAtom).isProcessing).toBe(false)
-		})
-	})
-})

+ 161 - 0
cli/src/state/atoms/__tests__/approval.test.ts

@@ -0,0 +1,161 @@
+/**
+ * Tests for approval atoms
+ */
+
+import { describe, it, expect } from "vitest"
+import { atom } from "jotai"
+import { createStore } from "jotai"
+import { approvalOptionsAtom, pendingApprovalAtom } from "../approval.js"
+import type { ExtensionChatMessage } from "../../../types/messages.js"
+
+// Helper to create a test message
+const createMessage = (ask: string, text: string = "{}"): ExtensionChatMessage => ({
+	type: "ask",
+	ask,
+	text,
+	ts: Date.now(),
+	partial: false,
+	isAnswered: false,
+	say: "assistant",
+})
+
+describe("approval atoms", () => {
+	describe("approvalOptionsAtom", () => {
+		it("should return empty array when no pending approval", () => {
+			const store = createStore()
+			const options = store.get(approvalOptionsAtom)
+			expect(options).toEqual([])
+		})
+
+		it("should return basic options for tool requests", () => {
+			const store = createStore()
+			const message = createMessage("tool", JSON.stringify({ tool: "readFile" }))
+			store.set(pendingApprovalAtom, message)
+
+			const options = store.get(approvalOptionsAtom)
+			expect(options).toHaveLength(2)
+			expect(options[0].action).toBe("approve")
+			expect(options[1].action).toBe("reject")
+		})
+
+		it("should return Save label for file operations", () => {
+			const store = createStore()
+			const message = createMessage("tool", JSON.stringify({ tool: "editedExistingFile" }))
+			store.set(pendingApprovalAtom, message)
+
+			const options = store.get(approvalOptionsAtom)
+			expect(options[0].label).toBe("Save")
+		})
+
+		describe("command approval options", () => {
+			it("should generate hierarchical options for simple command (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "git")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(3) // Run Command, Always Run "git", Reject
+				expect(options[0].label).toBe("Run Command")
+				expect(options[0].action).toBe("approve")
+				expect(options[1].label).toBe('Always Run "git"')
+				expect(options[1].action).toBe("approve-and-remember")
+				expect(options[1].commandPattern).toBe("git")
+				expect(options[2].label).toBe("Reject")
+				expect(options[2].action).toBe("reject")
+			})
+
+			it("should generate hierarchical options for simple command (JSON format)", () => {
+				const store = createStore()
+				const message = createMessage("command", JSON.stringify({ command: "git" }))
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(3) // Run Command, Always Run "git", Reject
+				expect(options[0].label).toBe("Run Command")
+				expect(options[0].action).toBe("approve")
+				expect(options[1].label).toBe('Always Run "git"')
+				expect(options[1].action).toBe("approve-and-remember")
+				expect(options[1].commandPattern).toBe("git")
+				expect(options[2].label).toBe("Reject")
+				expect(options[2].action).toBe("reject")
+			})
+
+			it("should generate hierarchical options for command with subcommand (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "git status")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(4) // Run Command, Always Run "git", Always Run "git status", Reject
+				expect(options[0].label).toBe("Run Command")
+				expect(options[1].label).toBe('Always Run "git"')
+				expect(options[1].commandPattern).toBe("git")
+				expect(options[2].label).toBe('Always Run "git status"')
+				expect(options[2].commandPattern).toBe("git status")
+				expect(options[3].label).toBe("Reject")
+			})
+
+			it("should generate hierarchical options for full command with flags (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "git status --short --branch")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(5) // Run Command, 3 Always Run options, Reject
+				expect(options[0].label).toBe("Run Command")
+				expect(options[1].label).toBe('Always Run "git"')
+				expect(options[1].commandPattern).toBe("git")
+				expect(options[2].label).toBe('Always Run "git status"')
+				expect(options[2].commandPattern).toBe("git status")
+				expect(options[3].label).toBe('Always Run "git status --short --branch"')
+				expect(options[3].commandPattern).toBe("git status --short --branch")
+				expect(options[4].label).toBe("Reject")
+			})
+
+			it("should assign hotkeys correctly (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "git status --short")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options[0].hotkey).toBe("y") // Run Command
+				expect(options[1].hotkey).toBe("1") // Always Run "git"
+				expect(options[2].hotkey).toBe("2") // Always Run "git status"
+				expect(options[3].hotkey).toBe("3") // Always Run "git status --short --branch"
+				expect(options[4].hotkey).toBe("n") // Reject
+			})
+
+			it("should handle commands with extra whitespace (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "  npm   install  ")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(4) // Run Command, Always run npm, Always run npm install, Reject
+				expect(options[1].commandPattern).toBe("npm")
+				expect(options[2].commandPattern).toBe("npm install")
+			})
+
+			it("should handle empty command gracefully (plain text)", () => {
+				const store = createStore()
+				const message = createMessage("command", "")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				expect(options).toHaveLength(2) // Run Command, Reject (no Always run options)
+			})
+
+			it("should handle invalid JSON as plain text command", () => {
+				const store = createStore()
+				const message = createMessage("command", "invalid json")
+				store.set(pendingApprovalAtom, message)
+
+				const options = store.get(approvalOptionsAtom)
+				// "invalid json" is treated as a command with two parts: "invalid" and "invalid json"
+				expect(options).toHaveLength(4) // Run Command, Always run invalid, Always run invalid json, Reject
+				expect(options[1].commandPattern).toBe("invalid")
+				expect(options[2].commandPattern).toBe("invalid json")
+			})
+		})
+	})
+})

+ 111 - 36
cli/src/state/atoms/approval.ts

@@ -8,9 +8,13 @@ import { selectedIndexAtom } from "./ui.js"
  */
 export interface ApprovalOption {
 	label: string
-	action: "approve" | "reject"
+	action: "approve" | "reject" | "approve-and-remember"
 	hotkey: string
 	color: "green" | "red"
+	/** Command pattern to remember (for approve-and-remember action) */
+	commandPattern?: string
+	/** Unique key for React rendering (combines action + pattern) */
+	key?: string
 }
 
 /**
@@ -52,8 +56,41 @@ export const isApprovalPendingAtom = atom<boolean>((get) => {
 	return pending !== null && !processing.isProcessing
 })
 
+/**
+ * Helper function to parse command into hierarchical parts
+ * Example: "git status --short --branch" returns:
+ * - "git"
+ * - "git status"
+ * - "git status --short --branch"
+ */
+function parseCommandHierarchy(command: string): string[] {
+	const parts = command.trim().split(/\s+/).filter(Boolean)
+	if (parts.length === 0) return []
+
+	const hierarchy: string[] = []
+
+	// Add base command
+	if (parts[0]) {
+		hierarchy.push(parts[0])
+	}
+
+	// Add command + first subcommand if exists
+	if (parts.length > 1 && parts[0] && parts[1]) {
+		hierarchy.push(`${parts[0]} ${parts[1]}`)
+	}
+
+	// Add full command if it's different from the previous entries
+	if (parts.length > 2) {
+		hierarchy.push(command)
+	}
+
+	return hierarchy
+}
+
 /**
  * Derived atom to get approval options based on the pending message type
+ * Note: This atom recalculates whenever the pending message changes OR when
+ * the message text/partial status changes (for streaming messages)
  */
 export const approvalOptionsAtom = atom<ApprovalOption[]>((get) => {
 	const pendingMessage = get(pendingApprovalAtom)
@@ -62,6 +99,26 @@ export const approvalOptionsAtom = atom<ApprovalOption[]>((get) => {
 		return []
 	}
 
+	// For command messages, wait until the message is complete (not partial)
+	// and has text before generating hierarchical options
+	if (pendingMessage.ask === "command" && (pendingMessage.partial || !pendingMessage.text)) {
+		// Return basic options while waiting for complete message
+		return [
+			{
+				label: "Run Command",
+				action: "approve" as const,
+				hotkey: "y",
+				color: "green" as const,
+			},
+			{
+				label: "Reject",
+				action: "reject" as const,
+				hotkey: "n",
+				color: "red" as const,
+			},
+		]
+	}
+
 	// Determine button labels based on ask type
 	let approveLabel = "Approve"
 	const rejectLabel = "Reject"
@@ -81,6 +138,50 @@ export const approvalOptionsAtom = atom<ApprovalOption[]>((get) => {
 		}
 	} else if (pendingMessage.ask === "command") {
 		approveLabel = "Run Command"
+
+		// Parse command and generate hierarchical approval options
+		let command = ""
+		try {
+			// Try parsing as JSON first
+			const commandData = JSON.parse(pendingMessage.text || "{}")
+			command = commandData.command || ""
+		} catch {
+			// If not JSON, use the text directly as the command
+			command = pendingMessage.text || ""
+		}
+
+		if (command) {
+			const hierarchy = parseCommandHierarchy(command)
+			const options: ApprovalOption[] = [
+				{
+					label: approveLabel,
+					action: "approve" as const,
+					hotkey: "y",
+					color: "green" as const,
+				},
+			]
+
+			// Add "Always run X" options for each level of the hierarchy
+			hierarchy.forEach((pattern, index) => {
+				options.push({
+					label: `Always Run "${pattern}"`,
+					action: "approve-and-remember" as const,
+					hotkey: String(index + 1),
+					color: "green" as const,
+					commandPattern: pattern,
+					key: `approve-and-remember-${pattern}`,
+				})
+			})
+
+			options.push({
+				label: rejectLabel,
+				action: "reject" as const,
+				hotkey: "n",
+				color: "red" as const,
+			})
+
+			return options
+		}
 	}
 
 	return [
@@ -108,29 +209,24 @@ export const setPendingApprovalAtom = atom(null, (get, set, message: ExtensionCh
 
 	// Don't set pending approval if we're currently processing an approval
 	if (processing.isProcessing) {
-		logs.debug("Skipping setPendingApproval - approval is being processed", "approval", {
-			processingTs: processing.processingTs,
-			newTs: message?.ts,
-		})
 		return
 	}
 
 	// Don't set if message is already answered
 	if (message?.isAnswered) {
-		logs.debug("Skipping setPendingApproval - message already answered", "approval", {
-			ts: message.ts,
-		})
 		return
 	}
 
-	logs.debug("Setting pending approval", "approval", {
-		ts: message?.ts,
-		ask: message?.ask,
-		text: message?.text?.substring(0, 100),
-	})
+	// Create a new object reference to force Jotai to re-evaluate dependent atoms
+	// This is important for streaming messages that update from partial to complete
+	const messageToSet = message ? { ...message } : null
+	set(pendingApprovalAtom, messageToSet)
 
-	set(pendingApprovalAtom, message)
-	set(selectedIndexAtom, 0) // Reset selection
+	// Only reset selection if this is a new message (different timestamp)
+	const current = get(pendingApprovalAtom)
+	if (!current || current.ts !== message?.ts) {
+		set(selectedIndexAtom, 0)
+	}
 })
 
 /**
@@ -141,14 +237,6 @@ export const clearPendingApprovalAtom = atom(null, (get, set) => {
 	const current = get(pendingApprovalAtom)
 	const processing = get(approvalProcessingAtom)
 
-	if (current) {
-		logs.debug("Clearing pending approval atom", "approval", {
-			ts: current.ts,
-			ask: current.ask,
-			wasProcessing: processing.isProcessing,
-		})
-	}
-
 	set(pendingApprovalAtom, null)
 	set(selectedIndexAtom, 0)
 
@@ -179,11 +267,6 @@ export const startApprovalProcessingAtom = atom(null, (get, set, operation: "app
 		return false
 	}
 
-	logs.debug("Starting approval processing", "approval", {
-		ts: pending.ts,
-		operation,
-	})
-
 	set(approvalProcessingAtom, {
 		isProcessing: true,
 		processingTs: pending.ts,
@@ -198,14 +281,6 @@ export const startApprovalProcessingAtom = atom(null, (get, set, operation: "app
  * This clears both the pending approval and processing state atomically
  */
 export const completeApprovalProcessingAtom = atom(null, (get, set) => {
-	const processing = get(approvalProcessingAtom)
-
-	logs.debug("Completing approval processing", "approval", {
-		ts: processing.processingTs,
-		operation: processing.operation,
-	})
-
-	// Clear both pending approval and processing state atomically
 	set(pendingApprovalAtom, null)
 	set(selectedIndexAtom, 0)
 	set(approvalProcessingAtom, { isProcessing: false })

+ 31 - 0
cli/src/state/atoms/config.ts

@@ -460,3 +460,34 @@ export const updateAutoApprovalSettingAtom = atom(
 		logs.info(`Auto approval ${category} setting updated`, "ConfigAtoms")
 	},
 )
+
+/**
+ * Action atom to add a command pattern to the auto-approval allowed list
+ */
+export const addAllowedCommandAtom = atom(null, async (get, set, commandPattern: string) => {
+	const config = get(configAtom)
+	const currentAllowed = config.autoApproval?.execute?.allowed ?? []
+
+	// Don't add if already exists
+	if (currentAllowed.includes(commandPattern)) {
+		logs.debug("Command pattern already in allowed list", "ConfigAtoms", { commandPattern })
+		return
+	}
+
+	const updatedConfig = {
+		...config,
+		autoApproval: {
+			...config.autoApproval,
+			execute: {
+				...config.autoApproval?.execute,
+				enabled: true, // Enable execute auto-approval when adding patterns
+				allowed: [...currentAllowed, commandPattern],
+			},
+		},
+	}
+
+	set(configAtom, updatedConfig)
+	await set(saveConfigAtom, updatedConfig)
+
+	logs.info(`Added command pattern to allowed list: ${commandPattern}`, "ConfigAtoms")
+})

+ 28 - 0
cli/src/state/atoms/ui.ts

@@ -236,6 +236,34 @@ export const lastMessageAtom = atom<CliMessage | null>((get) => {
 	return messages.length > 0 ? (messages[messages.length - 1] ?? null) : null
 })
 
+/**
+ * Derived atom to get the last ask message from extension messages
+ * Returns the most recent unanswered ask message that requires user approval, or null if none exists
+ */
+export const lastAskMessageAtom = atom<ExtensionChatMessage | null>((get) => {
+	const messages = get(chatMessagesAtom)
+
+	// Ask types that require user approval (not auto-handled)
+	const approvalAskTypes = [
+		"tool",
+		"command",
+		"followup",
+		"api_req_failed",
+		"browser_action_launch",
+		"use_mcp_server",
+	]
+
+	// Find the last unanswered ask message that requires approval
+	for (let i = messages.length - 1; i >= 0; i--) {
+		const msg = messages[i]
+		if (msg && msg.type === "ask" && !msg.isAnswered && msg.ask && approvalAskTypes.includes(msg.ask)) {
+			return msg
+		}
+	}
+
+	return null
+})
+
 /**
  * Derived atom to check if there's an active error
  */

+ 1 - 1
cli/src/state/hooks/index.ts

@@ -65,7 +65,7 @@ export { useTheme } from "./useTheme.js"
 export { useApprovalHandler } from "./useApprovalHandler.js"
 export type { UseApprovalHandlerOptions, UseApprovalHandlerReturn } from "./useApprovalHandler.js"
 
-export { useApprovalEffect } from "./useApprovalEffect.js"
+export { useApprovalMonitor } from "./useApprovalMonitor.js"
 
 export { useFollowupSuggestions } from "./useFollowupSuggestions.js"
 export type { UseFollowupSuggestionsReturn } from "./useFollowupSuggestions.js"

+ 40 - 1
cli/src/state/hooks/useApprovalHandler.ts

@@ -16,6 +16,8 @@ import {
 	executeSelectedCallbackAtom,
 	type ApprovalOption,
 } from "../atoms/approval.js"
+import { addAllowedCommandAtom, autoApproveExecuteAllowedAtom } from "../atoms/config.js"
+import { updateChatMessageByTsAtom } from "../atoms/extension.js"
 import { useWebviewMessage } from "./useWebviewMessage.js"
 import type { ExtensionChatMessage } from "../../types/messages.js"
 import { logs } from "../../services/logs.js"
@@ -134,6 +136,14 @@ export function useApprovalHandler(): UseApprovalHandlerReturn {
 					ts: currentPendingApproval.ts,
 				})
 
+				// Mark message as answered locally BEFORE sending response
+				// This prevents lastAskMessageAtom from returning it again
+				const answeredMessage: ExtensionChatMessage = {
+					...currentPendingApproval,
+					isAnswered: true,
+				}
+				store.set(updateChatMessageByTsAtom, answeredMessage)
+
 				await sendAskResponse({
 					response: "yesButtonClicked",
 					...(text && { text }),
@@ -191,6 +201,14 @@ export function useApprovalHandler(): UseApprovalHandlerReturn {
 			try {
 				logs.debug("Rejecting request", "useApprovalHandler", { ask: currentPendingApproval.ask })
 
+				// Mark message as answered locally BEFORE sending response
+				// This prevents lastAskMessageAtom from returning it again
+				const answeredMessage: ExtensionChatMessage = {
+					...currentPendingApproval,
+					isAnswered: true,
+				}
+				store.set(updateChatMessageByTsAtom, answeredMessage)
+
 				await sendAskResponse({
 					response: "noButtonClicked",
 					...(text && { text }),
@@ -223,11 +241,32 @@ export function useApprovalHandler(): UseApprovalHandlerReturn {
 
 			if (selectedOption.action === "approve") {
 				await approve(text, images)
+			} else if (selectedOption.action === "approve-and-remember") {
+				// First add the command pattern to config
+				if (selectedOption.commandPattern) {
+					try {
+						logs.info("Adding command pattern to auto-approval list", "useApprovalHandler", {
+							pattern: selectedOption.commandPattern,
+						})
+						await store.set(addAllowedCommandAtom, selectedOption.commandPattern)
+
+						// Verify the config was updated
+						const updatedAllowed = store.get(autoApproveExecuteAllowedAtom)
+						logs.info("Command pattern added successfully - current allowed list", "useApprovalHandler", {
+							pattern: selectedOption.commandPattern,
+							allowedList: updatedAllowed,
+						})
+					} catch (error) {
+						logs.error("Failed to add command pattern to config", "useApprovalHandler", { error })
+					}
+				}
+				// Then approve the current command
+				await approve(text, images)
 			} else {
 				await reject(text, images)
 			}
 		},
-		[selectedOption, approve, reject],
+		[selectedOption, approve, reject, store],
 	)
 
 	// Set callbacks for keyboard handler to use

+ 70 - 83
cli/src/state/hooks/useApprovalEffect.ts → cli/src/state/hooks/useApprovalMonitor.ts

@@ -1,34 +1,22 @@
 /**
- * Centralized Approval Effect Hook
+ * Centralized Approval Monitor Hook
  *
- * This hook handles all approval orchestration for Ask messages.
- * It replaces the duplicated approval logic that was previously
- * scattered across multiple Ask message components.
+ * This hook monitors extension messages for ask messages and handles all approval orchestration.
+ * It replaces the distributed useApprovalEffect that was previously called from multiple components.
  *
- * RACE CONDITION FIXES:
- * - Uses atomic state operations to prevent duplicate approvals
- * - Single source of truth for approval processing
- * - Proper cleanup when messages are answered
- * - No stale closures - reads state from store at execution time
- * - Prevents re-processing same message on re-renders
+ * Similar to useFollowupHandler, this hook:
+ * - Monitors messages from a single source (chatMessagesAtom via lastAskMessageAtom)
+ * - Handles approval state management centrally
+ * - Executes auto-approval logic when appropriate
+ * - Cleans up when messages are answered
  *
- * PARTIAL MESSAGE HANDLING:
- * - Sets pending approval even for partial messages (allows UI to show approval modal immediately)
- * - Only triggers auto-approval when message is complete (partial=false)
- * - This ensures the approval modal appears as soon as the ask message arrives
- *
- * @module useApprovalEffect
+ * @module useApprovalMonitor
  */
 
 import { useEffect, useRef } from "react"
 import { useAtomValue, useSetAtom, useStore } from "jotai"
-import type { ExtensionChatMessage } from "../../types/messages.js"
-import {
-	setPendingApprovalAtom,
-	clearPendingApprovalAtom,
-	approvalProcessingAtom,
-	pendingApprovalAtom,
-} from "../atoms/approval.js"
+import { lastAskMessageAtom } from "../atoms/ui.js"
+import { setPendingApprovalAtom, clearPendingApprovalAtom, approvalProcessingAtom } from "../atoms/approval.js"
 import {
 	autoApproveReadAtom,
 	autoApproveReadOutsideAtom,
@@ -56,32 +44,29 @@ import { logs } from "../../services/logs.js"
 import { useApprovalTelemetry } from "./useApprovalTelemetry.js"
 
 /**
- * Hook that orchestrates approval flow for Ask messages
+ * Hook that monitors messages and orchestrates approval flow
  *
  * This hook:
- * 1. Sets the message as pending approval when it arrives
- * 2. Gets the approval decision from the service
- * 3. Executes auto-approve/reject based on the decision
- * 4. Handles timeouts and cleanup
- * 5. Clears pending approval when message is answered
- *
- * IMPORTANT: This is the ONLY place where auto-approval should be triggered.
- * Other components should only set pending approval, not execute approvals.
- *
- * @param message - The Ask message to handle
+ * 1. Watches for new ask messages via lastAskMessageAtom
+ * 2. Sets the message as pending approval when it arrives
+ * 3. Gets the approval decision from the service
+ * 4. Executes auto-approve/reject based on the decision
+ * 5. Handles timeouts and cleanup
+ * 6. Clears pending approval when message is answered
  *
  * @example
  * ```typescript
- * export const AskCommandMessage = ({ message }) => {
- *   useApprovalEffect(message)
+ * export const UI = () => {
+ *   // Call once at the top level
+ *   useApprovalMonitor()
  *
- *   // Just render UI
  *   return <Box>...</Box>
  * }
  * ```
  */
-export function useApprovalEffect(message: ExtensionChatMessage): void {
+export function useApprovalMonitor(): void {
 	const store = useStore()
+	const lastAskMessage = useAtomValue(lastAskMessageAtom)
 	const setPendingApproval = useSetAtom(setPendingApprovalAtom)
 	const clearPendingApproval = useSetAtom(clearPendingApprovalAtom)
 
@@ -110,8 +95,6 @@ export function useApprovalEffect(message: ExtensionChatMessage): void {
 
 	// Track if we've already handled auto-approval for this message timestamp
 	const autoApprovalHandledRef = useRef<Set<number>>(new Set())
-	// Track the last message timestamp we processed to prevent re-processing on re-renders
-	const lastProcessedTsRef = useRef<number | null>(null)
 
 	// Build config object with proper nested structure
 	const config: AutoApprovalConfig = {
@@ -154,85 +137,92 @@ export function useApprovalEffect(message: ExtensionChatMessage): void {
 		},
 	}
 
+	// Track the last message we set as pending (timestamp + partial state)
+	const lastPendingRef = useRef<{ ts: number; partial: boolean } | null>(null)
+
 	// Main effect: handle approval orchestration
 	useEffect(() => {
 		let timeoutId: NodeJS.Timeout | null = null
 
-		// CRITICAL: Check if message is answered FIRST, before any other checks
-		// This ensures we clear pending approval even if we've already processed this timestamp
-		if (message.isAnswered) {
+		// If no ask message, clear pending approval
+		if (!lastAskMessage) {
 			clearPendingApproval()
-			// Also clear the processed timestamp so we don't skip future messages
-			if (lastProcessedTsRef.current === message.ts) {
-				lastProcessedTsRef.current = null
-			}
+			lastPendingRef.current = null
 			return
 		}
 
-		// Skip if we've already processed this exact message timestamp
-		// This prevents re-processing on re-renders when the message object reference changes
-		if (lastProcessedTsRef.current === message.ts) {
+		// If message is answered, clear pending approval
+		if (lastAskMessage.isAnswered) {
+			clearPendingApproval()
+			lastPendingRef.current = null
 			return
 		}
 
 		// Check if we're already processing this message
 		const processingState = store.get(approvalProcessingAtom)
-		if (processingState.isProcessing && processingState.processingTs === message.ts) {
+		if (processingState.isProcessing && processingState.processingTs === lastAskMessage.ts) {
 			return
 		}
 
-		// Check if this message is already pending
-		const currentPending = store.get(pendingApprovalAtom)
-		if (currentPending?.ts === message.ts) {
-			// Don't set pending again, but continue with auto-approval check for complete messages
-		} else {
-			// Set pending approval even for partial messages (this allows UI to show approval modal)
-			// The approval modal will appear immediately, but auto-approval only happens when complete
-			setPendingApproval(message)
-		}
+		// Set as pending if:
+		// 1. This is a new message (different timestamp), OR
+		// 2. The message transitioned from partial to complete (need to update options)
+		const isNewMessage = !lastPendingRef.current || lastPendingRef.current.ts !== lastAskMessage.ts
+		const transitionedToComplete =
+			lastPendingRef.current &&
+			lastPendingRef.current.ts === lastAskMessage.ts &&
+			lastPendingRef.current.partial &&
+			!lastAskMessage.partial
 
-		// Mark this timestamp as processed
-		lastProcessedTsRef.current = message.ts
+		if (isNewMessage || transitionedToComplete) {
+			lastPendingRef.current = { ts: lastAskMessage.ts, partial: lastAskMessage.partial || false }
+			setPendingApproval(lastAskMessage)
+		}
 
 		// Handle auto-approval once per message timestamp, but ONLY for complete messages
 		// This allows the approval modal to show for partial messages while preventing premature auto-approval
-		if (!message.partial && !autoApprovalHandledRef.current.has(message.ts)) {
-			autoApprovalHandledRef.current.add(message.ts)
+		if (!lastAskMessage.partial && !autoApprovalHandledRef.current.has(lastAskMessage.ts)) {
+			autoApprovalHandledRef.current.add(lastAskMessage.ts)
 
 			// Get approval decision from service
-			const decision = getApprovalDecision(message, config, isCIMode)
+			const decision = getApprovalDecision(lastAskMessage, config, isCIMode)
 
 			// Execute based on decision
 			if (decision.action === "auto-approve") {
 				const delay = decision.delay || 0
 
 				if (delay > 0) {
-					logs.info(`Auto-approving ${message.ask} after ${delay / 1000}s delay`, "useApprovalEffect")
+					logs.info(`Auto-approving ${lastAskMessage.ask} after ${delay / 1000}s delay`, "useApprovalMonitor")
 					timeoutId = setTimeout(() => {
-						// Check if message is still pending before approving
-						const currentPending = store.get(pendingApprovalAtom)
-						if (currentPending?.ts === message.ts && !currentPending.isAnswered) {
+						// Check if message is still the current ask before approving
+						const currentAsk = store.get(lastAskMessageAtom)
+						if (currentAsk?.ts === lastAskMessage.ts && !currentAsk.isAnswered) {
 							approve(decision.message).catch((error) => {
-								logs.error(`Failed to auto-approve ${message.ask}`, "useApprovalEffect", { error })
+								logs.error(`Failed to auto-approve ${lastAskMessage.ask}`, "useApprovalMonitor", {
+									error,
+								})
 							})
 						}
 					}, delay)
 				} else {
-					logs.info(`${isCIMode ? "CI mode: " : ""}Auto-approving ${message.ask}`, "useApprovalEffect")
+					logs.info(
+						`${isCIMode ? "CI mode: " : ""}Auto-approving ${lastAskMessage.ask}`,
+						"useApprovalMonitor",
+					)
 					// Track auto-approval
-					approvalTelemetry.trackAutoApproval(message)
+					approvalTelemetry.trackAutoApproval(lastAskMessage)
 					// Execute approval immediately
 					approve(decision.message).catch((error) => {
-						logs.error(`Failed to auto-approve ${message.ask}`, "useApprovalEffect", { error })
+						logs.error(`Failed to auto-approve ${lastAskMessage.ask}`, "useApprovalMonitor", { error })
 					})
 				}
 			} else if (decision.action === "auto-reject") {
-				logs.info(`CI mode: Auto-rejecting ${message.ask}`, "useApprovalEffect")
+				logs.info(`CI mode: Auto-rejecting ${lastAskMessage.ask}`, "useApprovalMonitor")
 				// Track auto-rejection
-				approvalTelemetry.trackAutoRejection(message)
+				approvalTelemetry.trackAutoRejection(lastAskMessage)
 				// Execute rejection immediately
 				reject(decision.message).catch((error) => {
-					logs.error(`CI mode: Failed to auto-reject ${message.ask}`, "useApprovalEffect", { error })
+					logs.error(`CI mode: Failed to auto-reject ${lastAskMessage.ask}`, "useApprovalMonitor", { error })
 				})
 			}
 		}
@@ -244,10 +234,7 @@ export function useApprovalEffect(message: ExtensionChatMessage): void {
 			}
 		}
 	}, [
-		message.ts,
-		message.isAnswered,
-		message.partial,
-		message.ask,
+		lastAskMessage,
 		setPendingApproval,
 		clearPendingApproval,
 		approve,
@@ -255,12 +242,12 @@ export function useApprovalEffect(message: ExtensionChatMessage): void {
 		config,
 		isCIMode,
 		store,
+		approvalTelemetry,
 	])
 
-	// Cleanup: remove timestamp from handled set when message timestamp changes
+	// Cleanup: remove old timestamps to prevent memory leak
 	useEffect(() => {
 		return () => {
-			// Clean up old timestamps to prevent memory leak
 			// Keep only the last 100 timestamps
 			if (autoApprovalHandledRef.current.size > 100) {
 				const timestamps = Array.from(autoApprovalHandledRef.current)
@@ -268,5 +255,5 @@ export function useApprovalEffect(message: ExtensionChatMessage): void {
 				autoApprovalHandledRef.current = new Set(toKeep)
 			}
 		}
-	}, [message.ts])
+	}, [lastAskMessage?.ts])
 }

+ 4 - 0
cli/src/ui/UI.tsx

@@ -19,6 +19,7 @@ import { isCommandInput } from "../services/autocomplete.js"
 import { useCommandHandler } from "../state/hooks/useCommandHandler.js"
 import { useMessageHandler } from "../state/hooks/useMessageHandler.js"
 import { useFollowupHandler } from "../state/hooks/useFollowupHandler.js"
+import { useApprovalMonitor } from "../state/hooks/useApprovalMonitor.js"
 import { useProfile } from "../state/hooks/useProfile.js"
 import { useCIMode } from "../state/hooks/useCIMode.js"
 import { useTheme } from "../state/hooks/useTheme.js"
@@ -57,6 +58,9 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	// Followup handler hook for automatic suggestion population
 	useFollowupHandler()
 
+	// Approval monitor hook for centralized approval handling
+	useApprovalMonitor()
+
 	// Profile hook for handling profile/balance data responses
 	useProfile()
 

+ 5 - 1
cli/src/ui/components/ApprovalMenu.tsx

@@ -27,7 +27,11 @@ export const ApprovalMenu: React.FC<ApprovalMenuProps> = ({ options, selectedInd
 				[!] Action Required:
 			</Text>
 			{options.map((option, index) => (
-				<ApprovalOptionRow key={option.action} option={option} isSelected={index === selectedIndex} />
+				<ApprovalOptionRow
+					key={option.key || `${option.action}-${index}`}
+					option={option}
+					isSelected={index === selectedIndex}
+				/>
 			))}
 		</Box>
 	)

+ 0 - 1
cli/src/ui/messages/MessageRow.tsx

@@ -9,7 +9,6 @@ interface MessageRowProps {
 }
 
 export const MessageRow: React.FC<MessageRowProps> = ({ unifiedMessage }) => {
-	//logs.debug("Message", "MessageRow", { unifiedMessage })
 	if (unifiedMessage.source === "cli") {
 		return <CliMessageRow message={unifiedMessage.message} />
 	}

+ 0 - 1
cli/src/ui/messages/extension/ExtensionMessageRow.tsx

@@ -22,7 +22,6 @@ function ErrorFallback({ error }: { error: Error }) {
 
 export const ExtensionMessageRow: React.FC<ExtensionMessageRowProps> = ({ message }) => {
 	const theme = useTheme()
-	//logs.debug("Rendering ExtensionMessageRow", "ExtensionMessageRow", { message })
 
 	return (
 		<ErrorBoundary fallbackRender={ErrorFallback}>

+ 1 - 3
cli/src/ui/messages/extension/ask/AskBrowserActionLaunchMessage.tsx

@@ -2,16 +2,14 @@ import React from "react"
 import { Box, Text } from "ink"
 import type { MessageComponentProps } from "../types.js"
 import { getMessageIcon } from "../utils.js"
-import { useApprovalEffect } from "../../../../state/hooks/useApprovalEffect.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 
 /**
  * Display browser action launch request
+ * Approval is handled centrally by useApprovalMonitor in UI.tsx
  */
 export const AskBrowserActionLaunchMessage: React.FC<MessageComponentProps> = ({ message }) => {
 	const theme = useTheme()
-	// Use centralized approval orchestration
-	useApprovalEffect(message)
 
 	const icon = getMessageIcon("ask", "browser_action_launch")
 

+ 1 - 3
cli/src/ui/messages/extension/ask/AskCommandMessage.tsx

@@ -2,17 +2,15 @@ import React from "react"
 import { Box, Text } from "ink"
 import type { MessageComponentProps } from "../types.js"
 import { getMessageIcon, parseToolData } from "../utils.js"
-import { useApprovalEffect } from "../../../../state/hooks/useApprovalEffect.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 import { BOX_L3 } from "../../../utils/width.js"
 
 /**
  * Display command execution request with terminal icon and command in a bordered box
+ * Approval is handled centrally by useApprovalMonitor in UI.tsx
  */
 export const AskCommandMessage: React.FC<MessageComponentProps> = ({ message }) => {
 	const theme = useTheme()
-	// Use centralized approval orchestration
-	useApprovalEffect(message)
 
 	const icon = getMessageIcon("ask", "command")
 	const toolData = parseToolData(message)

+ 1 - 3
cli/src/ui/messages/extension/ask/AskToolMessage.tsx

@@ -2,18 +2,16 @@ import React from "react"
 import { Box, Text } from "ink"
 import type { MessageComponentProps } from "../types.js"
 import { parseToolData, getToolIcon } from "../utils.js"
-import { useApprovalEffect } from "../../../../state/hooks/useApprovalEffect.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 import { BOX_L3 } from "../../../utils/width.js"
 
 /**
  * Display tool usage requests requiring approval
  * Parses tool data and shows tool information
+ * Approval is handled centrally by useApprovalMonitor in UI.tsx
  */
 export const AskToolMessage: React.FC<MessageComponentProps> = ({ message }) => {
 	const theme = useTheme()
-	// Use centralized approval orchestration
-	useApprovalEffect(message)
 
 	const toolData = parseToolData(message)
 

+ 1 - 3
cli/src/ui/messages/extension/ask/AskUseMcpServerMessage.tsx

@@ -2,17 +2,15 @@ import React from "react"
 import { Box, Text } from "ink"
 import type { MessageComponentProps } from "../types.js"
 import { getMessageIcon, parseMcpServerData } from "../utils.js"
-import { useApprovalEffect } from "../../../../state/hooks/useApprovalEffect.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 import { BOX_L3 } from "../../../utils/width.js"
 
 /**
  * Display MCP server usage request (tool or resource access)
+ * Approval is handled centrally by useApprovalMonitor in UI.tsx
  */
 export const AskUseMcpServerMessage: React.FC<MessageComponentProps> = ({ message }) => {
 	const theme = useTheme()
-	// Use centralized approval orchestration
-	useApprovalEffect(message)
 
 	const icon = getMessageIcon("ask", "use_mcp_server")
 	const mcpData = parseMcpServerData(message)

+ 1 - 8
cli/src/ui/messages/extension/tools/ToolRouter.tsx

@@ -1,7 +1,6 @@
 import React from "react"
 import { Box, Text } from "ink"
 import type { ToolMessageProps } from "../types.js"
-import { useApprovalEffect } from "../../../../state/hooks/useApprovalEffect.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 import {
 	ToolEditedExistingFileMessage,
@@ -25,16 +24,10 @@ import {
 
 /**
  * Routes tool data to the appropriate component based on tool type
- *
- * RACE CONDITION FIX:
- * - Removed duplicate approval logic (now handled by useApprovalEffect)
- * - This component only handles routing and rendering
+ * Approval is handled centrally by useApprovalMonitor in UI.tsx
  */
 export const ToolRouter: React.FC<ToolMessageProps> = ({ message, toolData }) => {
 	const theme = useTheme()
-	// Use centralized approval orchestration
-	// This handles all approval logic including auto-approval
-	useApprovalEffect(message)
 
 	switch (toolData.tool) {
 		case "editedExistingFile":

+ 0 - 54
cli/src/ui/messages/utils/messageCompletion.ts

@@ -9,7 +9,6 @@ import type { UnifiedMessage } from "../../../state/atoms/ui.js"
 import type { ExtensionChatMessage } from "../../../types/messages.js"
 import type { CliMessage } from "../../../types/cli.js"
 import { parseApiReqInfo } from "../extension/utils.js"
-import { logs } from "../../../services/logs.js"
 
 /**
  * Determines if a CLI message is complete
@@ -17,12 +16,6 @@ import { logs } from "../../../services/logs.js"
  */
 function isCliMessageComplete(message: CliMessage): boolean {
 	const isComplete = message.partial !== true
-	logs.debug("CLI message completion check", "messageCompletion", {
-		id: message.id,
-		type: message.type,
-		partial: message.partial,
-		isComplete,
-	})
 	return isComplete
 }
 
@@ -38,12 +31,6 @@ function isCliMessageComplete(message: CliMessage): boolean {
 function isExtensionMessageComplete(message: ExtensionChatMessage): boolean {
 	// Handle partial flag first - if partial is explicitly true, not complete
 	if (message.partial === true) {
-		logs.debug("Extension message incomplete: partial=true", "messageCompletion", {
-			ts: message.ts,
-			type: message.type,
-			say: message.say,
-			ask: message.ask,
-		})
 		return false
 	}
 
@@ -52,13 +39,6 @@ function isExtensionMessageComplete(message: ExtensionChatMessage): boolean {
 	if (message.say === "api_req_started") {
 		const apiInfo = parseApiReqInfo(message)
 		const isComplete = !!(apiInfo?.streamingFailedMessage || apiInfo?.cancelReason || apiInfo?.cost !== undefined)
-		logs.debug("api_req_started completion check", "messageCompletion", {
-			ts: message.ts,
-			hasStreamingFailed: !!apiInfo?.streamingFailedMessage,
-			hasCancelReason: !!apiInfo?.cancelReason,
-			hasCost: apiInfo?.cost !== undefined,
-			isComplete,
-		})
 		return isComplete
 	}
 
@@ -67,10 +47,6 @@ function isExtensionMessageComplete(message: ExtensionChatMessage): boolean {
 		// These ask types don't render, so they're immediately complete
 		const nonRenderingAskTypes = ["completion_result", "command_output"]
 		if (message.ask && nonRenderingAskTypes.includes(message.ask)) {
-			logs.debug("Ask message complete (non-rendering type)", "messageCompletion", {
-				ts: message.ts,
-				ask: message.ask,
-			})
 			return true
 		}
 
@@ -78,25 +54,9 @@ function isExtensionMessageComplete(message: ExtensionChatMessage): boolean {
 		// They don't need to wait for isAnswered since they're just displaying
 		// the request and waiting for user interaction
 		const isComplete = !message.partial
-		logs.debug("Ask message completion check", "messageCompletion", {
-			ts: message.ts,
-			ask: message.ask,
-			isAnswered: message.isAnswered,
-			isAnsweredType: typeof message.isAnswered,
-			partial: message.partial,
-			isComplete,
-			reason: isComplete ? "not partial" : "still partial",
-		})
 		return isComplete
 	}
 
-	// All other messages are complete if not partial
-	logs.debug("Extension message complete (default)", "messageCompletion", {
-		ts: message.ts,
-		type: message.type,
-		say: message.say,
-		partial: message.partial,
-	})
 	return true
 }
 
@@ -129,10 +89,6 @@ function deduplicateCheckpointMessages(messages: UnifiedMessage[]): UnifiedMessa
 			const hash = msg.message.text.trim()
 			if (seenCheckpointHashes.has(hash)) {
 				// Skip duplicate checkpoint
-				logs.debug("Skipping duplicate checkpoint message", "messageCompletion", {
-					ts: msg.message.ts,
-					hash,
-				})
 				continue
 			}
 			seenCheckpointHashes.add(hash)
@@ -211,16 +167,6 @@ export function splitMessages(messages: UnifiedMessage[]): {
 	const staticMessages = deduplicatedMessages.slice(0, lastCompleteIndex + 1)
 	const dynamicMessages = deduplicatedMessages.slice(lastCompleteIndex + 1)
 
-	logs.debug("Message split summary", "messageCompletion", {
-		originalCount: messages.length,
-		deduplicatedCount: deduplicatedMessages.length,
-		totalMessages: deduplicatedMessages.length,
-		staticCount: staticMessages.length,
-		dynamicCount: dynamicMessages.length,
-		lastCompleteIndex,
-		incompleteReasons: incompleteReasons.length > 0 ? incompleteReasons : "All messages complete",
-	})
-
 	return {
 		staticMessages,
 		dynamicMessages,