diff --git a/crates/executors/src/executors/claude.rs b/crates/executors/src/executors/claude.rs index 8f4d87a3..c596c150 100644 --- a/crates/executors/src/executors/claude.rs +++ b/crates/executors/src/executors/claude.rs @@ -39,6 +39,10 @@ async fn get_backend_port() -> std::io::Result { const CONFIRM_HOOK_SCRIPT: &str = include_str!("./hooks/confirm.py"); +/// Natural language marker we add in our Python hook to denote user feedback +/// This marker is added by our confirm.py hook script and is robust to Claude Code format changes +const USER_FEEDBACK_MARKER: &str = "User feedback: "; + fn base_command(claude_code_router: bool) -> &'static str { if claude_code_router { "npx -y @musistudio/claude-code-router@1.0.49 code" @@ -229,10 +233,6 @@ async fn write_python_hook(current_dir: &Path) -> Result<(), ExecutorError> { tokio::fs::create_dir_all(&hooks_dir).await?; let hook_path = hooks_dir.join("confirm.py"); - if tokio::fs::try_exists(&hook_path).await? { - return Ok(()); - } - let mut file = tokio::fs::File::create(&hook_path).await?; file.write_all(CONFIRM_HOOK_SCRIPT.as_bytes()).await?; file.flush().await?; @@ -276,7 +276,7 @@ async fn settings_json(plan: bool) -> Result { "hooks": [ { "type": "command", - "command": format!("$CLAUDE_PROJECT_DIR/.claude/hooks/confirm.py --timeout-seconds {backend_timeout} --poll-interval 5 --backend-port {backend_port}"), + "command": format!("$CLAUDE_PROJECT_DIR/.claude/hooks/confirm.py --timeout-seconds {backend_timeout} --poll-interval 5 --backend-port {backend_port} --feedback-marker '{USER_FEEDBACK_MARKER}'"), "timeout": backend_timeout + 10 } ] @@ -313,6 +313,33 @@ exit "$exit_code" ) } +/// Extract user denial reason from tool result error messages +/// Our confirm.py hook prefixes user feedback with "User feedback: " for easy extraction +/// Supports both string content and Claude's array format: [{"type":"text","text":"..."}] +fn extract_denial_reason(content: &serde_json::Value) -> Option { + // First try to parse as string + let content_str = if let Some(s) = content.as_str() { + s.to_string() + } else if let Ok(items) = + serde_json::from_value::>(content.clone()) + { + // Handle array format: [{"type":"text","text":"..."}] + items + .into_iter() + .map(|item| item.text) + .collect::>() + .join("\n") + } else { + // Try to serialize the value as a string + content.to_string() + }; + + content_str + .split_once(USER_FEEDBACK_MARKER) + .map(|(_, rest)| rest.trim().to_string()) + .filter(|s| !s.is_empty()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HistoryStrategy { // Claude-code format @@ -539,6 +566,27 @@ impl ClaudeLogProcessor { info.tool_data, ClaudeToolData::Bash { .. } ); + + // Capture the display name for user feedback + let display_tool_name = if is_command { + info.tool_name.clone() + } else { + // For non-command tools, use the same label logic as below + let raw_name = + info.tool_data.get_name().to_string(); + if raw_name.starts_with("mcp__") { + let parts: Vec<&str> = + raw_name.split("__").collect(); + if parts.len() >= 3 { + format!("mcp:{}:{}", parts[1], parts[2]) + } else { + raw_name + } + } else { + raw_name + } + }; + if is_command { // For bash commands, attach result as CommandRun output where possible // Prefer parsing Amp's claude-compatible Bash format: {"output":"...","exitCode":0} @@ -674,6 +722,27 @@ impl ClaudeLogProcessor { ); } } + + // Check if this is a denial with user feedback (for both command and non-command tools) + if is_error.unwrap_or(false) + && let Some(denial_reason) = + extract_denial_reason(content) + { + let user_feedback = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::UserFeedback { + denied_tool: display_tool_name.clone(), + }, + content: denial_reason, + metadata: None, + }; + msg_store.push_patch( + ConversationPatch::add_normalized_entry( + entry_index_provider.next(), + user_feedback, + ), + ); + } } } } diff --git a/crates/executors/src/executors/hooks/confirm.py b/crates/executors/src/executors/hooks/confirm.py index af611f78..35562628 100755 --- a/crates/executors/src/executors/hooks/confirm.py +++ b/crates/executors/src/executors/hooks/confirm.py @@ -8,13 +8,18 @@ import urllib.request from typing import Optional -def json_error(reason: Optional[str]) -> None: +def json_error(reason: Optional[str], feedback_marker: Optional[str] = None) -> None: """Emit a deny PreToolUse JSON to stdout and exit(0).""" + # Prefix user feedback with marker for extraction if provided + formatted_reason = reason + if reason and feedback_marker: + formatted_reason = f"{feedback_marker}{reason}" + payload = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": reason, + "permissionDecisionReason": formatted_reason, } } print(json.dumps(payload, ensure_ascii=False)) @@ -93,6 +98,13 @@ def parse_args() -> argparse.Namespace: required=True, help="Port of the approval backend running on 127.0.0.1.", ) + parser.add_argument( + "-m", + "--feedback-marker", + type=str, + required=True, + help="Marker prefix for user feedback messages.", + ) args = parser.parse_args() if args.timeout_seconds <= 0: @@ -147,7 +159,7 @@ def main(): json_success() elif status == "denied": reason = result.get("reason") - json_error(reason) + json_error(reason, args.feedback_marker) elif status == "timed_out": # concat to avoid triggering the watchkill script json_error( diff --git a/crates/executors/src/logs/mod.rs b/crates/executors/src/logs/mod.rs index 11f0c6fe..3cf3776d 100644 --- a/crates/executors/src/logs/mod.rs +++ b/crates/executors/src/logs/mod.rs @@ -52,6 +52,9 @@ pub struct NormalizedConversation { #[serde(tag = "type", rename_all = "snake_case")] pub enum NormalizedEntryType { UserMessage, + UserFeedback { + denied_tool: String, + }, AssistantMessage, ToolUse { tool_name: String, diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 7ca0a770..36e4d8bc 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -49,7 +49,7 @@ const renderJson = (v: JsonValue) => ( const getEntryIcon = (entryType: NormalizedEntryType) => { const iconSize = 'h-3 w-3'; - if (entryType.type === 'user_message') { + if (entryType.type === 'user_message' || entryType.type === 'user_feedback') { return ; } if (entryType.type === 'assistant_message') { @@ -600,6 +600,7 @@ function DisplayConversationEntry({ executionProcessId, taskAttempt, }: Props) { + const { t } = useTranslation('common'); const isNormalizedEntry = ( entry: NormalizedEntry | ProcessStartPayload ): entry is NormalizedEntry => 'entry_type' in entry; @@ -630,6 +631,7 @@ function DisplayConversationEntry({ const isError = entryType.type === 'error_message'; const isToolUse = entryType.type === 'tool_use'; const isUserMessage = entryType.type === 'user_message'; + const isUserFeedback = entryType.type === 'user_feedback'; const isLoading = entryType.type === 'loading'; const isFileEdit = (a: ActionType): a is FileEditAction => a.action === 'file_edit'; @@ -643,6 +645,31 @@ function DisplayConversationEntry({ /> ); } + + if (isUserFeedback) { + const feedbackEntry = entryType as Extract< + NormalizedEntryType, + { type: 'user_feedback' } + >; + return ( +
+
+
+ {t('conversation.deniedByUser', { + toolName: feedbackEntry.denied_tool, + })} +
+ +
+
+ ); + } const renderToolUse = () => { if (!isNormalizedEntry(entry)) return null; if (entryType.type !== 'tool_use') return null; diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index c238e991..85952967 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -34,6 +34,7 @@ }, "args": "Args", "output": "Output", - "result": "Result" + "result": "Result", + "deniedByUser": "{{toolName}} denied by user" } } diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index a1b62d32..9c1b6ff3 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -21,5 +21,8 @@ }, "language": { "browserDefault": "Predeterminado del navegador" + }, + "conversation": { + "deniedByUser": "{{toolName}} denegado por el usuario" } } diff --git a/frontend/src/i18n/locales/ja/common.json b/frontend/src/i18n/locales/ja/common.json index 76a8ea8d..58e0589d 100644 --- a/frontend/src/i18n/locales/ja/common.json +++ b/frontend/src/i18n/locales/ja/common.json @@ -34,6 +34,7 @@ }, "args": "引数", "output": "出力", - "result": "結果" + "result": "結果", + "deniedByUser": "{{toolName}} がユーザーによって拒否されました" } } diff --git a/shared/types.ts b/shared/types.ts index 7b1c83b7..96e45099 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -286,7 +286,7 @@ export type CommandRunResult = { exit_status: CommandExitStatus | null, output: export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, }; -export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, status: ToolStatus, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" } | { "type": "loading" }; +export type NormalizedEntryType = { "type": "user_message" } | { "type": "user_feedback", denied_tool: string, } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, status: ToolStatus, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" } | { "type": "loading" }; export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit", /**