From 022d7d7b6ca0ff801a60dab27df5dfbc33ab341a Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 09:33:03 -0700 Subject: [PATCH 1/6] feat: handle CwdChanged and FileChanged hook events (closes #253) --- src-tauri/src/types.rs | 62 ++++++++++++++ src-tauri/src/wsl_bridge.rs | 157 +++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 73a1f57..2864eb4 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -318,6 +318,20 @@ pub struct PostCompactEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CwdChangedEvent { + pub cwd: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileChangedEvent { + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -741,4 +755,52 @@ mod tests { assert!(serialized.contains("\"session_id\":\"sess-xyz\"")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_cwd_changed_event_serialization() { + let event = CwdChangedEvent { + cwd: "/home/naomi/code/my-project".to_string(), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"cwd\":\"/home/naomi/code/my-project\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_cwd_changed_event_omits_none_fields() { + let event = CwdChangedEvent { + cwd: "/tmp/workspace".to_string(), + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"cwd\":\"/tmp/workspace\"")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_file_changed_event_serialization() { + let event = FileChangedEvent { + file: "/home/naomi/code/my-project/src/main.rs".to_string(), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"file\":\"/home/naomi/code/my-project/src/main.rs\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_file_changed_event_omits_none_fields() { + let event = FileChangedEvent { + file: "/tmp/test.txt".to_string(), + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"file\":\"/tmp/test.txt\"")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 1eef472..2775058 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -15,10 +15,10 @@ use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, - ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, - OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, - PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, - UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, + FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, + PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TodoItem, + TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1078,6 +1078,8 @@ fn handle_stderr( let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_stop_failure = line.contains("[StopFailure Hook]"); let is_post_compact = line.contains("[PostCompact Hook]"); + let is_cwd_changed = line.contains("[CwdChanged Hook]"); + let is_file_changed = line.contains("[FileChanged Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1089,6 +1091,10 @@ fn handle_stderr( "error" } else if is_post_compact { "compact-prompt" + } else if is_cwd_changed { + "cwd-changed" + } else if is_file_changed { + "file-changed" } else { "error" }; @@ -1227,6 +1233,57 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_cwd_changed { + let data = parse_cwd_changed_hook(&line); + let friendly_content = + format!("Working directory changed to: {}", data.cwd); + + let _ = app.emit( + "claude:cwd-changed", + CwdChangedEvent { + cwd: data.cwd, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "cwd-changed".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_file_changed { + let data = parse_file_changed_hook(&line); + let friendly_content = if data.file.is_empty() { + "File changed".to_string() + } else { + format!("File changed: {}", data.file) + }; + + let _ = app.emit( + "claude:file-changed", + FileChangedEvent { + file: data.file, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "file-changed".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1440,6 +1497,42 @@ fn parse_post_compact_hook(line: &str) -> PostCompactData { PostCompactData { session_id } } +#[derive(Debug)] +struct CwdChangedData { + cwd: String, +} + +fn parse_cwd_changed_hook(line: &str) -> CwdChangedData { + let cwd = extract_quoted_value(line, "cwd") + .or_else(|| extract_quoted_value(line, "path")) + .or_else(|| { + line.split("[CwdChanged Hook]") + .nth(1) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_default(); + CwdChangedData { cwd } +} + +#[derive(Debug)] +struct FileChangedData { + file: String, +} + +fn parse_file_changed_hook(line: &str) -> FileChangedData { + let file = extract_quoted_value(line, "file") + .or_else(|| extract_quoted_value(line, "path")) + .or_else(|| { + line.split("[FileChanged Hook]") + .nth(1) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_default(); + FileChangedData { file } +} + /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3588,6 +3681,62 @@ mod tests { assert_eq!(data.session_id, None); } + #[test] + fn test_parse_cwd_changed_hook_with_cwd_key() { + let line = r#"[CwdChanged Hook] cwd="/home/naomi/code/my-project""#; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/home/naomi/code/my-project"); + } + + #[test] + fn test_parse_cwd_changed_hook_with_path_key() { + let line = r#"[CwdChanged Hook] path="/tmp/workspace""#; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/tmp/workspace"); + } + + #[test] + fn test_parse_cwd_changed_hook_bare_path_fallback() { + let line = "[CwdChanged Hook] /home/naomi/code/project"; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, "/home/naomi/code/project"); + } + + #[test] + fn test_parse_cwd_changed_hook_empty_line() { + let line = "[CwdChanged Hook]"; + let data = parse_cwd_changed_hook(line); + assert_eq!(data.cwd, ""); + } + + #[test] + fn test_parse_file_changed_hook_with_file_key() { + let line = r#"[FileChanged Hook] file="/home/naomi/code/project/src/main.rs""#; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/home/naomi/code/project/src/main.rs"); + } + + #[test] + fn test_parse_file_changed_hook_with_path_key() { + let line = r#"[FileChanged Hook] path="/tmp/test.txt""#; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/tmp/test.txt"); + } + + #[test] + fn test_parse_file_changed_hook_bare_path_fallback() { + let line = "[FileChanged Hook] /home/naomi/code/project/README.md"; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, "/home/naomi/code/project/README.md"); + } + + #[test] + fn test_parse_file_changed_hook_empty_line() { + let line = "[FileChanged Hook]"; + let data = parse_file_changed_hook(line); + assert_eq!(data.file, ""); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData { -- 2.52.0 From 6fe7e97550d0e776cc26b3fde1923479285b3e80 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 09:58:09 -0700 Subject: [PATCH 2/6] feat: handle TaskCreated hook event (closes #254) --- src-tauri/src/types.rs | 57 +++++++++++++++++++++ src-tauri/src/wsl_bridge.rs | 98 ++++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 2864eb4..0f04038 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -332,6 +332,18 @@ pub struct FileChangedEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCreatedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -803,4 +815,49 @@ mod tests { assert!(serialized.contains("\"file\":\"/tmp/test.txt\"")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_task_created_event_serialization() { + let event = TaskCreatedEvent { + task_id: Some("task-abc123".to_string()), + description: Some("Explore the codebase".to_string()), + parent_tool_use_id: Some("toolu_xyz".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"task_id\":\"task-abc123\"")); + assert!(serialized.contains("\"description\":\"Explore the codebase\"")); + assert!(serialized.contains("\"parent_tool_use_id\":\"toolu_xyz\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_task_created_event_omits_none_fields() { + let event = TaskCreatedEvent { + task_id: None, + description: None, + parent_tool_use_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert_eq!(serialized, "{}"); + } + + #[test] + fn test_task_created_event_partial_fields() { + let event = TaskCreatedEvent { + task_id: Some("task-001".to_string()), + description: None, + parent_tool_use_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"task_id\":\"task-001\"")); + assert!(!serialized.contains("description")); + assert!(!serialized.contains("parent_tool_use_id")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 2775058..819ece3 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -17,8 +17,9 @@ use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, - PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TodoItem, - TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, + TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, + WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1080,6 +1081,7 @@ fn handle_stderr( let is_post_compact = line.contains("[PostCompact Hook]"); let is_cwd_changed = line.contains("[CwdChanged Hook]"); let is_file_changed = line.contains("[FileChanged Hook]"); + let is_task_created = line.contains("[TaskCreated Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1095,6 +1097,8 @@ fn handle_stderr( "cwd-changed" } else if is_file_changed { "file-changed" + } else if is_task_created { + "task-created" } else { "error" }; @@ -1284,6 +1288,34 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_task_created { + let data = parse_task_created_hook(&line); + let friendly_content = match data.description.as_deref() { + Some(desc) if !desc.is_empty() => format!("Task created: {}", desc), + _ => "Task created".to_string(), + }; + + let _ = app.emit( + "claude:task-created", + TaskCreatedEvent { + task_id: data.task_id, + description: data.description, + parent_tool_use_id: data.parent_tool_use_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "task-created".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1533,6 +1565,32 @@ fn parse_file_changed_hook(line: &str) -> FileChangedData { FileChangedData { file } } +#[derive(Debug)] +struct TaskCreatedData { + task_id: Option, + description: Option, + parent_tool_use_id: Option, +} + +fn parse_task_created_hook(line: &str) -> TaskCreatedData { + let task_id = extract_debug_string_value(line, "task_id") + .or_else(|| extract_quoted_value(line, "task_id")); + + let description = extract_quoted_value(line, "description") + .or_else(|| extract_quoted_value(line, "prompt")); + + let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { + line.split("parent_tool_use_id=Some(\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .map(|s| s.to_string()) + } else { + None + }; + + TaskCreatedData { task_id, description, parent_tool_use_id } +} + /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3737,6 +3795,42 @@ mod tests { assert_eq!(data.file, ""); } + #[test] + fn test_parse_task_created_hook_with_all_fields() { + let line = r#"[TaskCreated Hook] task_id=Some("task-abc123"), description="Explore the codebase", parent_tool_use_id=Some("toolu_xyz")"#; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, Some("task-abc123".to_string())); + assert_eq!(data.description, Some("Explore the codebase".to_string())); + assert_eq!(data.parent_tool_use_id, Some("toolu_xyz".to_string())); + } + + #[test] + fn test_parse_task_created_hook_with_description_only() { + let line = r#"[TaskCreated Hook] description="Search for relevant files""#; + let data = parse_task_created_hook(line); + assert_eq!(data.description, Some("Search for relevant files".to_string())); + assert_eq!(data.task_id, None); + assert_eq!(data.parent_tool_use_id, None); + } + + #[test] + fn test_parse_task_created_hook_no_parent() { + let line = r#"[TaskCreated Hook] task_id=Some("task-001"), description="Run tests""#; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, Some("task-001".to_string())); + assert_eq!(data.description, Some("Run tests".to_string())); + assert_eq!(data.parent_tool_use_id, None); + } + + #[test] + fn test_parse_task_created_hook_empty_line() { + let line = "[TaskCreated Hook]"; + let data = parse_task_created_hook(line); + assert_eq!(data.task_id, None); + assert_eq!(data.description, None); + assert_eq!(data.parent_tool_use_id, None); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData { -- 2.52.0 From 5846b3052976c3a7c009347a046247937f057d05 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 10:03:31 -0700 Subject: [PATCH 3/6] feat: handle PermissionDenied hook event and drive Permission character state (closes #256) --- src-tauri/src/types.rs | 50 +++++++++++++++++++ src-tauri/src/wsl_bridge.rs | 99 +++++++++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0f04038..097b67f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -344,6 +344,16 @@ pub struct TaskCreatedEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionDeniedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -860,4 +870,44 @@ mod tests { assert!(!serialized.contains("parent_tool_use_id")); assert!(!serialized.contains("conversation_id")); } + + #[test] + fn test_permission_denied_event_serialization() { + let event = PermissionDeniedEvent { + tool_name: Some("Bash".to_string()), + reason: Some("Tool not in allow list".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"tool_name\":\"Bash\"")); + assert!(serialized.contains("\"reason\":\"Tool not in allow list\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_permission_denied_event_omits_none_fields() { + let event = PermissionDeniedEvent { + tool_name: None, + reason: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert_eq!(serialized, "{}"); + } + + #[test] + fn test_permission_denied_event_partial_fields() { + let event = PermissionDeniedEvent { + tool_name: Some("Edit".to_string()), + reason: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"tool_name\":\"Edit\"")); + assert!(!serialized.contains("reason")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 819ece3..ffdbb2c 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -16,10 +16,10 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, - FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, - PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, - TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, - WorktreeEvent, WorktreeInfo, + FileChangedEvent, MessageCost, OutputEvent, PermissionDeniedEvent, PermissionPromptEvent, + PermissionPromptEventItem, PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, + StopFailureEvent, TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, + WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1082,6 +1082,7 @@ fn handle_stderr( let is_cwd_changed = line.contains("[CwdChanged Hook]"); let is_file_changed = line.contains("[FileChanged Hook]"); let is_task_created = line.contains("[TaskCreated Hook]"); + let is_permission_denied = line.contains("[PermissionDenied Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" @@ -1099,6 +1100,8 @@ fn handle_stderr( "file-changed" } else if is_task_created { "task-created" + } else if is_permission_denied { + "permission-denied" } else { "error" }; @@ -1316,6 +1319,45 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_permission_denied { + let data = parse_permission_denied_hook(&line); + let friendly_content = match (data.tool_name.as_deref(), data.reason.as_deref()) { + (Some(tool), Some(reason)) => { + format!("Permission denied for {}: {}", tool, reason) + } + (Some(tool), None) => format!("Permission denied for {}", tool), + _ => "Permission denied".to_string(), + }; + + let _ = app.emit( + "claude:state-change", + StateChangeEvent { + state: CharacterState::Permission, + tool_name: data.tool_name.clone(), + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:permission-denied", + PermissionDeniedEvent { + tool_name: data.tool_name, + reason: data.reason, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "permission-denied".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1591,6 +1633,23 @@ fn parse_task_created_hook(line: &str) -> TaskCreatedData { TaskCreatedData { task_id, description, parent_tool_use_id } } +#[derive(Debug)] +struct PermissionDeniedData { + tool_name: Option, + reason: Option, +} + +fn parse_permission_denied_hook(line: &str) -> PermissionDeniedData { + let tool_name = extract_quoted_value(line, "tool_name") + .or_else(|| extract_quoted_value(line, "tool")) + .or_else(|| extract_debug_string_value(line, "tool_name")); + + let reason = extract_quoted_value(line, "reason") + .or_else(|| extract_quoted_value(line, "message")); + + PermissionDeniedData { tool_name, reason } +} + /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3831,6 +3890,38 @@ mod tests { assert_eq!(data.parent_tool_use_id, None); } + #[test] + fn test_parse_permission_denied_hook_with_all_fields() { + let line = r#"[PermissionDenied Hook] tool_name="Bash", reason="Tool not in allow list""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Bash".to_string())); + assert_eq!(data.reason, Some("Tool not in allow list".to_string())); + } + + #[test] + fn test_parse_permission_denied_hook_tool_only() { + let line = r#"[PermissionDenied Hook] tool_name="Edit""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Edit".to_string())); + assert_eq!(data.reason, None); + } + + #[test] + fn test_parse_permission_denied_hook_tool_key_alias() { + let line = r#"[PermissionDenied Hook] tool="Write", reason="Workspace not trusted""#; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, Some("Write".to_string())); + assert_eq!(data.reason, Some("Workspace not trusted".to_string())); + } + + #[test] + fn test_parse_permission_denied_hook_empty_line() { + let line = "[PermissionDenied Hook]"; + let data = parse_permission_denied_hook(line); + assert_eq!(data.tool_name, None); + assert_eq!(data.reason, None); + } + #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData { -- 2.52.0 From 54b3a524c442bc973d0c9b86370ca94075603901 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 10:07:20 -0700 Subject: [PATCH 4/6] chore: handle Monitor tool in stream parsing (closes #258) --- src-tauri/src/wsl_bridge.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index ffdbb2c..7919d4d 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -37,7 +37,10 @@ struct PendingToolUse { tool_input: serde_json::Value, } -const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; +// "Monitor" added in Claude Code v2.1.98 — it streams events and is observational in nature, +// so it maps to the Searching character state. It may appear as name "Monitor" in tool_use +// blocks (confirmed by CLI source inspection; it is a local_bash with kind="monitor" internally). +const SEARCH_TOOLS: [&str; 6] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch", "Monitor"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; fn detect_wsl() -> bool { @@ -2756,6 +2759,11 @@ mod tests { get_tool_state("WebFetch"), CharacterState::Searching )); + // Monitor tool added in v2.1.98 — observational/streaming, maps to Searching + assert!(matches!( + get_tool_state("Monitor"), + CharacterState::Searching + )); } #[test] -- 2.52.0 From edea33310beb7ac0eeb168fd3cbf735616301e4c Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 10:19:50 -0700 Subject: [PATCH 5/6] feat: expose disableSkillShellExecution setting in config UI (closes #259) --- src-tauri/src/config.rs | 11 +++++++++++ src-tauri/src/wsl_bridge.rs | 16 ++++++++++++++-- src/lib/components/ConfigSidebar.svelte | 17 +++++++++++++++++ src/lib/components/StatusBar.svelte | 1 + src/lib/stores/config.test.ts | 3 +++ src/lib/stores/config.ts | 3 +++ 6 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 2fb18f4..fc89406 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -54,6 +54,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub session_name: Option, + + #[serde(default)] + pub disable_skill_shell_execution: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -203,6 +206,11 @@ pub struct HikariConfig { #[serde(default)] pub model_overrides: Option>, + + /// Prevents skill scripts from executing shell commands (Claude Code v2.1.91+). + /// Passes `"disableSkillShellExecution": true` via the `--settings` flag. + #[serde(default)] + pub disable_skill_shell_execution: bool, } impl Default for HikariConfig { @@ -254,6 +262,7 @@ impl Default for HikariConfig { enable_claudeai_mcp_servers: true, auto_memory_directory: None, model_overrides: None, + disable_skill_shell_execution: false, } } } @@ -405,6 +414,7 @@ mod tests { assert!(config.enable_claudeai_mcp_servers); assert!(config.auto_memory_directory.is_none()); assert!(config.model_overrides.is_none()); + assert!(!config.disable_skill_shell_execution); } #[test] @@ -459,6 +469,7 @@ mod tests { "claude-opus-4-6".to_string(), "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(), )])), + disable_skill_shell_execution: true, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 7919d4d..6a8f9a6 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -316,7 +316,7 @@ impl WslBridge { .map(|m| !m.is_empty()) .unwrap_or(false); - if has_memory_dir || has_overrides { + if has_memory_dir || has_overrides || options.disable_skill_shell_execution { let mut settings = serde_json::Map::new(); if let Some(ref dir) = options.auto_memory_directory { if !dir.is_empty() { @@ -333,6 +333,12 @@ impl WslBridge { } } } + if options.disable_skill_shell_execution { + settings.insert( + "disableSkillShellExecution".to_string(), + serde_json::Value::Bool(true), + ); + } if let Ok(settings_json) = serde_json::to_string(&settings) { cmd.args(["--settings", &settings_json]); } @@ -503,7 +509,7 @@ impl WslBridge { .map(|m| !m.is_empty()) .unwrap_or(false); - if has_memory_dir || has_overrides { + if has_memory_dir || has_overrides || options.disable_skill_shell_execution { let mut settings = serde_json::Map::new(); if let Some(ref dir) = options.auto_memory_directory { if !dir.is_empty() { @@ -520,6 +526,12 @@ impl WslBridge { } } } + if options.disable_skill_shell_execution { + settings.insert( + "disableSkillShellExecution".to_string(), + serde_json::Value::Bool(true), + ); + } if let Ok(settings_json) = serde_json::to_string(&settings) { let escaped = settings_json.replace('\'', "'\\''"); claude_cmd.push_str(&format!(" --settings '{}'", escaped)); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 0a26a2b..b9ccb1b 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -63,6 +63,7 @@ enable_claudeai_mcp_servers: true, auto_memory_directory: null, model_overrides: null, + disable_skill_shell_execution: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, @@ -585,6 +586,22 @@

+ +
+ +

+ Passes disableSkillShellExecution: true to prevent skill scripts + from executing shell commands (requires Claude Code v2.1.91+) +

+
+