From 9890b8331373658ba3b50831fed312c4723f6ab9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 13:39:34 -0800 Subject: [PATCH 01/12] feat: workspace trust gate with persistent trust decisions Adds a pre-connection trust check that detects hooks, MCP servers, and custom slash commands in a workspace's .claude/ config before launching Claude Code. Shows a trust modal (permission sprite) listing all concerns by category. Trusted workspaces are persisted to config so the user is only prompted once per workspace path. Closes #163 --- src-tauri/src/commands.rs | 114 ++++++++++++++++++ src-tauri/src/config.rs | 6 + src-tauri/src/lib.rs | 1 + src/lib/components/ConfigSidebar.svelte | 1 + src/lib/components/StatusBar.svelte | 65 +++++++++- src/lib/components/WorkspaceTrustModal.svelte | 110 +++++++++++++++++ src/lib/stores/config.test.ts | 3 + src/lib/stores/config.ts | 3 + src/lib/types/messages.ts | 7 ++ 9 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/WorkspaceTrustModal.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ec07102..03b286e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -334,6 +334,120 @@ pub async fn answer_question( manager.send_tool_result(&conversation_id, &tool_use_id, answers) } +#[derive(Debug, Serialize)] +pub struct WorkspaceHookInfo { + pub has_concerns: bool, + pub hook_types: Vec, + pub mcp_servers: Vec, + pub custom_commands: Vec, +} + +/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands. +/// +/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode), +/// which bypasses Claude's own workspace trust dialog. We therefore check for these +/// ourselves so the frontend can show its own trust gate before launching. +#[tauri::command] +pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo { + let use_wsl = cfg!(windows) && working_dir.starts_with('/'); + + let settings_paths = [ + format!("{}/.claude/settings.json", working_dir), + format!("{}/.claude/settings.local.json", working_dir), + ]; + + let mut all_hook_types: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut all_mcp_servers: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for path in &settings_paths { + let content = if use_wsl { + match read_file_via_wsl(path).await { + Ok(c) => c, + Err(_) => continue, + } + } else { + match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + } + }; + + let settings: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => continue, + }; + + if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) { + for key in hooks.keys() { + all_hook_types.insert(key.clone()); + } + } + + if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) { + for key in servers.keys() { + all_mcp_servers.insert(key.clone()); + } + } + } + + let custom_commands = list_workspace_commands(&working_dir, use_wsl).await; + let hook_types: Vec = all_hook_types.into_iter().collect(); + let mcp_servers: Vec = all_mcp_servers.into_iter().collect(); + let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty(); + + WorkspaceHookInfo { + has_concerns, + hook_types, + mcp_servers, + custom_commands, + } +} + +async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec { + let commands_dir = format!("{}/.claude/commands", working_dir); + + if use_wsl { + let script = format!( + "if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi", + commands_dir + ); + let Ok(output) = std::process::Command::new("wsl") + .args(["-e", "sh", "-c", &script]) + .output() + else { + return vec![]; + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + } else { + let dir = std::path::Path::new(&commands_dir); + if !dir.exists() { + return vec![]; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return vec![]; + }; + let mut names: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + }) + .filter_map(|e| { + e.path() + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + }) + .collect(); + names.sort(); + names + } +} + #[tauri::command] pub async fn list_skills() -> Result, String> { // On Windows, we need to use WSL to access the skills directory diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6a21c23..91e5a2e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -125,6 +125,9 @@ pub struct HikariConfig { #[serde(default)] pub disable_1m_context: bool, + + #[serde(default)] + pub trusted_workspaces: Vec, } impl Default for HikariConfig { @@ -159,6 +162,7 @@ impl Default for HikariConfig { discord_rpc_enabled: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: Vec::new(), } } } @@ -268,6 +272,7 @@ mod tests { assert!(config.discord_rpc_enabled); assert!(!config.use_worktree); assert!(!config.disable_1m_context); + assert!(config.trusted_workspaces.is_empty()); } #[test] @@ -302,6 +307,7 @@ mod tests { discord_rpc_enabled: true, use_worktree: true, disable_1m_context: false, + trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70aea97..e1b8b18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,7 @@ pub fn run() { get_persisted_stats, load_saved_achievements, answer_question, + check_workspace_hooks, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index c4905f0..d22896c 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -55,6 +55,7 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: [], }); let showCustomThemeEditor = $state(false); diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index e4099b9..eee7695 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -38,6 +38,8 @@ } from "$lib/utils/conversationUtils"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { debugConsoleStore } from "$lib/stores/debugConsole"; + import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte"; + import type { WorkspaceHookInfo } from "$lib/types/messages"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -61,6 +63,8 @@ let showPluginPanel = $state(false); let showMcpPanel = $state(false); let isSummarising = $state(false); + let showWorkspaceTrust = $state(false); + let pendingHookInfo: WorkspaceHookInfo | null = $state(null); const progress = $derived($achievementProgress); const activeAgentCount = $derived($runningAgentCount); let currentConfig: HikariConfig = $state({ @@ -103,6 +107,7 @@ show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, + trusted_workspaces: [], }); let streamerModeActive = $state(false); @@ -156,11 +161,7 @@ } } - async function handleConnect() { - if (isConnecting || connectionStatus === "connected") return; - - const targetDir = selectedDirectory || "/home/naomi"; - + async function doConnect(targetDir: string) { // Combine session-granted tools with config auto-granted tools const allAllowedTools = [ ...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]), @@ -200,6 +201,52 @@ } } + async function handleConnect() { + if (isConnecting || connectionStatus === "connected") return; + + const targetDir = selectedDirectory || "/home/naomi"; + + if (currentConfig.trusted_workspaces?.includes(targetDir)) { + await doConnect(targetDir); + return; + } + + try { + const hookInfo = await invoke("check_workspace_hooks", { + workingDir: targetDir, + }); + + if (hookInfo.has_concerns) { + pendingHookInfo = hookInfo; + showWorkspaceTrust = true; + return; + } + } catch (error) { + // Fail open: if we can't check hooks, proceed with connection + console.error("Failed to check workspace hooks:", error); + } + + await doConnect(targetDir); + } + + async function handleTrustAndConnect() { + showWorkspaceTrust = false; + const targetDir = selectedDirectory || "/home/naomi"; + pendingHookInfo = null; + const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false; + if (!alreadyTrusted) { + await configStore.updateConfig({ + trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir], + }); + } + doConnect(targetDir); + } + + function handleCancelConnect() { + showWorkspaceTrust = false; + pendingHookInfo = null; + } + async function handleDisconnect() { try { const conversationId = get(claudeStore.activeConversationId); @@ -771,6 +818,14 @@ (showMcpPanel = false)} /> {/if} +{#if showWorkspaceTrust && pendingHookInfo} + +{/if} + -- 2.52.0 From 5fb3f1a44b09d98fed518084a7a14ce0a18bbb9b Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 18:29:59 -0800 Subject: [PATCH 05/12] fix: escape raw HTML in markdown renderer (#169) Prevents HTML tags from being rendered as live DOM elements in terminal output. Overrides the marked codespan and html renderers to escape < and > before inserting into the output HTML. --- src/lib/components/Markdown.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 671ae48..7560e98 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -35,7 +35,12 @@ }; renderer.codespan = ({ text }) => { - return `${text}`; + const escaped = text.replace(//g, ">"); + return `${escaped}`; + }; + + renderer.html = ({ text }) => { + return text.replace(//g, ">"); }; marked.setOptions({ -- 2.52.0 From dfc3d71c4255992ccbc38724f052b36a305e1922 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 19:02:39 -0800 Subject: [PATCH 06/12] fix: suppress terminal window flash on Windows for all subprocesses (#165) --- src-tauri/src/commands.rs | 32 ++++++++++++++++++++++++++++-- src-tauri/src/git.rs | 3 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/notifications.rs | 6 ++++++ src-tauri/src/process_ext.rs | 21 ++++++++++++++++++++ src-tauri/src/vbs_notification.rs | 5 ++++- src-tauri/src/wsl_bridge.rs | 13 +++++++----- src-tauri/src/wsl_notifications.rs | 4 ++++ 8 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/process_ext.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 30d81fd..efc58c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt; use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent}; use crate::bridge_manager::SharedBridgeManager; use crate::config::{ClaudeStartOptions, HikariConfig}; +use crate::process_ext::HideWindow; use crate::stats::UsageStats; use crate::temp_manager::SharedTempFileManager; @@ -59,6 +60,7 @@ fn create_claude_command() -> std::process::Command { // Non-login shells launched by `wsl` don't inherit the full user PATH, // so we need to use a login shell to get the correct PATH let which_output = std::process::Command::new("wsl") + .hide_window() .args(["-e", "bash", "-l", "-c", "which claude"]) .output(); @@ -66,6 +68,7 @@ fn create_claude_command() -> std::process::Command { Ok(output) if output.status.success() => { let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let mut cmd = std::process::Command::new("wsl"); + cmd.hide_window(); cmd.arg(claude_path); cmd } @@ -73,6 +76,7 @@ fn create_claude_command() -> std::process::Command { // Fallback to just "claude" if which fails // This maintains backwards compatibility let mut cmd = std::process::Command::new("wsl"); + cmd.hide_window(); cmd.arg("claude"); cmd } @@ -85,18 +89,23 @@ fn create_claude_command() -> std::process::Command { // This works regardless of how Claude Code was installed (standalone, npm, etc.) // and avoids hardcoding paths let which_output = std::process::Command::new("which") + .hide_window() .arg("claude") .output(); match which_output { Ok(output) if output.status.success() => { let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - std::process::Command::new(claude_path) + let mut cmd = std::process::Command::new(claude_path); + cmd.hide_window(); + cmd } _ => { // Fallback to just "claude" if which fails // This maintains backwards compatibility - std::process::Command::new("claude") + let mut cmd = std::process::Command::new("claude"); + cmd.hide_window(); + cmd } } } @@ -412,6 +421,7 @@ async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec Result, String> { // Use WSL to list directories in ~/.claude/skills that contain SKILL.md let output = Command::new("wsl") + .hide_window() .args([ "-e", "sh", @@ -794,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result, String> { ); let output = Command::new("wsl") + .hide_window() .args(["-e", "sh", "-c", &script]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -856,6 +868,7 @@ async fn read_file_via_wsl(path: &str) -> Result { use std::process::Command; let output = Command::new("wsl") + .hide_window() .args(["-e", "cat", path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -887,6 +900,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> { use std::process::{Command, Stdio}; let mut child = Command::new("wsl") + .hide_window() .args(["-e", "sh", "-c", &format!("cat > '{}'", path)]) .stdin(Stdio::piped()) .spawn() @@ -935,6 +949,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> { // Check if file exists first let check = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -944,6 +959,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> { } let output = Command::new("wsl") + .hide_window() .args(["-e", "touch", path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -984,6 +1000,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> { // Check if directory exists first let check = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -993,6 +1010,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> { } let output = Command::new("wsl") + .hide_window() .args(["-e", "mkdir", "-p", path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1037,6 +1055,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> { // Check if path exists let check_exists = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1047,6 +1066,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> { // Check if path is a directory let check_dir = Command::new("wsl") + .hide_window() .args(["-e", "test", "-d", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1056,6 +1076,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> { } let output = Command::new("wsl") + .hide_window() .args(["-e", "rm", path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1100,6 +1121,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> { // Check if path exists let check_exists = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1110,6 +1132,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> { // Check if path is a directory let check_dir = Command::new("wsl") + .hide_window() .args(["-e", "test", "-d", path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1119,6 +1142,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> { } let output = Command::new("wsl") + .hide_window() .args(["-e", "rm", "-rf", path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1164,6 +1188,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin // Check if old path exists let check_old = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", old_path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1174,6 +1199,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin // Check if new path already exists let check_new = Command::new("wsl") + .hide_window() .args(["-e", "test", "-e", new_path]) .status() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1183,6 +1209,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin } let output = Command::new("wsl") + .hide_window() .args(["-e", "mv", old_path, new_path]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; @@ -1360,6 +1387,7 @@ async fn list_memory_files_via_wsl() -> Result { "#; let output = Command::new("wsl") + .hide_window() .args(["-e", "bash", "-l", "-c", script]) .output() .map_err(|e| format!("Failed to execute WSL command: {}", e))?; diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 8a3fd9e..356b16a 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::process::Command; +use crate::process_ext::HideWindow; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitStatus { pub is_repo: bool, @@ -37,6 +39,7 @@ pub struct GitLogEntry { fn run_git_command(working_dir: &str, args: &[&str]) -> Result { let output = Command::new("git") + .hide_window() .args(args) .current_dir(working_dir) .output() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1b8b18..0c0d9b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod debug_logger; mod discord_rpc; mod git; mod notifications; +mod process_ext; mod quick_actions; mod sessions; mod snippets; diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 590fc35..976087a 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -1,6 +1,8 @@ use std::process::Command; use tauri::command; +use crate::process_ext::HideWindow; + /// Generate PowerShell script for Windows Toast Notification fn generate_powershell_toast_script(title: &str, body: &str) -> String { format!( @@ -82,6 +84,7 @@ fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec Result<(), String> { // Use notify-send for Linux/WSL let output = Command::new("notify-send") + .hide_window() .arg(&title) .arg(&body) .arg("--urgency=normal") @@ -109,6 +112,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<() // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell let output = Command::new("pwsh.exe") + .hide_window() .arg("-NoProfile") .arg("-WindowStyle") .arg("Hidden") @@ -117,6 +121,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<() .output() .or_else(|_| { Command::new("powershell.exe") + .hide_window() .arg("-NoProfile") .arg("-WindowStyle") .arg("Hidden") @@ -140,6 +145,7 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(), let message = format_simple_notification(&title, &body); Command::new("cmd.exe") + .hide_window() .arg("/c") .arg("msg") .arg("*") diff --git a/src-tauri/src/process_ext.rs b/src-tauri/src/process_ext.rs new file mode 100644 index 0000000..49c3d57 --- /dev/null +++ b/src-tauri/src/process_ext.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +/// Extension trait for `Command` that hides the console window on Windows. +/// +/// On non-Windows platforms this is a no-op, so callers can unconditionally +/// chain `.hide_window()` without any `#[cfg]` guards at the call sites. +pub trait HideWindow { + fn hide_window(&mut self) -> &mut Self; +} + +impl HideWindow for Command { + fn hide_window(&mut self) -> &mut Self { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + self.creation_flags(CREATE_NO_WINDOW); + } + self + } +} diff --git a/src-tauri/src/vbs_notification.rs b/src-tauri/src/vbs_notification.rs index f100d91..c3a6670 100644 --- a/src-tauri/src/vbs_notification.rs +++ b/src-tauri/src/vbs_notification.rs @@ -3,6 +3,8 @@ use std::process::Command; use tauri::command; use tempfile::NamedTempFile; +use crate::process_ext::HideWindow; + #[command] pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { // Create a VBScript that shows a Windows notification @@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 } else if temp_path.starts_with("/tmp/") { // WSL temp files might be in a different location // Try to use wslpath to convert - let output = Command::new("wslpath").arg("-w").arg(&temp_path).output(); + let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output(); if let Ok(result) = output { if result.status.success() { @@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 // Execute the VBScript using wscript.exe let output = Command::new("/mnt/c/Windows/System32/wscript.exe") + .hide_window() .arg("//NoLogo") .arg(&windows_path) .output() diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 77119c6..6cca66a 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -12,6 +12,7 @@ use std::os::windows::process::CommandExt; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::commands::record_cost; use crate::config::ClaudeStartOptions; +use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, @@ -89,7 +90,7 @@ fn find_claude_binary() -> Option { // Use a login shell to resolve claude via the user's PATH - GUI apps don't // inherit shell PATH, so bare `which` may miss ~/.local/bin entries - if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() { + if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { @@ -224,6 +225,7 @@ impl WslBridge { tracing::debug!("Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); + cmd.hide_window(); cmd.args([ "--output-format", "stream-json", @@ -291,6 +293,7 @@ impl WslBridge { // Check if Claude binary is installed inside WSL let binary_check = Command::new("wsl") + .hide_window() .args(["-e", "bash", "-lc", "which claude"]) .output(); if let Ok(output) = binary_check { @@ -301,6 +304,7 @@ impl WslBridge { // Validate the working directory exists inside WSL before spawning let dir_check = Command::new("wsl") + .hide_window() .args(["-e", "test", "-d", working_dir]) .output(); if let Ok(output) = dir_check { @@ -375,8 +379,7 @@ impl WslBridge { cmd.args(["-e", "bash", "-lc", &claude_cmd]); // Hide the console window on Windows - #[cfg(target_os = "windows")] - cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + cmd.hide_window(); cmd }; @@ -2059,7 +2062,7 @@ mod tests { #[test] fn test_stale_process_detection_with_try_wait() { // Spawn a real process that exits immediately so we can verify try_wait detects it - let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'"); + let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'"); // Wait for it to exit let _ = child.wait(); @@ -2078,7 +2081,7 @@ mod tests { fn test_stale_process_is_some_after_exit() { // Verify the logic used in start(): a process that has exited is detected // and the handle is cleaned up so start() can proceed - let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'"); + let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'"); // Let it exit let _ = child.wait(); diff --git a/src-tauri/src/wsl_notifications.rs b/src-tauri/src/wsl_notifications.rs index 2b71121..c928590 100644 --- a/src-tauri/src/wsl_notifications.rs +++ b/src-tauri/src/wsl_notifications.rs @@ -1,6 +1,8 @@ use std::process::Command; use tauri::command; +use crate::process_ext::HideWindow; + #[command] pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> { // Method 1: Try Windows 10/11 toast notification using PowerShell @@ -36,6 +38,7 @@ $notifier.Show($toast) // Try PowerShell.exe through WSL let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") + .hide_window() .arg("-NoProfile") .arg("-ExecutionPolicy") .arg("Bypass") @@ -65,6 +68,7 @@ $notifier.Show($toast) // Method 3: Try wsl-notify-send if available let notify_result = Command::new("wsl-notify-send") + .hide_window() .arg("--appId") .arg("HikariDesktop") .arg("--category") -- 2.52.0 From dbf5234a42d92d80c36c5680886c0ac60ef83a96 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 20:06:18 -0800 Subject: [PATCH 07/12] fix: watchdog for hung wsl bridge process (#166) Adds a 60-second watchdog that silently kills the Claude Code process if system:init never arrives, preventing the UI from being stuck in a "Connected" state indefinitely. Refactors process handle to Arc>> for cross-thread access, and removes the unused CommandExt import. --- src-tauri/src/wsl_bridge.rs | 96 +++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 6cca66a..b8b4fe1 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1,14 +1,13 @@ use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use parking_lot::Mutex; use tauri::{AppHandle, Emitter}; use tempfile::NamedTempFile; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; - use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::commands::record_cost; use crate::config::ClaudeStartOptions; @@ -103,52 +102,58 @@ fn find_claude_binary() -> Option { } pub struct WslBridge { - process: Option, + process: Arc>>, stdin: Option, working_directory: String, session_id: Option, mcp_config_file: Option, stats: Arc>, conversation_id: Option, + /// Set to true once the `system:init` message arrives, false at the start of every new session. + received_init: Arc, } impl WslBridge { pub fn new() -> Self { WslBridge { - process: None, + process: Arc::new(Mutex::new(None)), stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), conversation_id: None, + received_init: Arc::new(AtomicBool::new(false)), } } pub fn new_with_conversation_id(conversation_id: String) -> Self { WslBridge { - process: None, + process: Arc::new(Mutex::new(None)), stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), conversation_id: Some(conversation_id), + received_init: Arc::new(AtomicBool::new(false)), } } pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { // If a process handle exists but the process has already exited (e.g. due to a // failed working directory), clean up the stale handle so we can restart cleanly. - if let Some(ref mut process) = self.process { - if process.try_wait().map(|s| s.is_some()).unwrap_or(false) { - self.process = None; - self.stdin = None; + { + let mut proc_guard = self.process.lock(); + if let Some(ref mut proc) = *proc_guard { + if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) { + *proc_guard = None; + self.stdin = None; + } + } + if proc_guard.is_some() { + return Err("Process already running".to_string()); } - } - - if self.process.is_some() { - return Err("Process already running".to_string()); } // Load saved achievements and stats when starting a new session @@ -399,7 +404,10 @@ impl WslBridge { let stderr = child.stderr.take(); self.stdin = stdin; - self.process = Some(child); + *self.process.lock() = Some(child); + + // Reset the init flag so the watchdog and stdout handler start fresh. + self.received_init.store(false, Ordering::SeqCst); // Note: We no longer reset stats here - stats persist across reconnects // Stats are only reset when explicitly disconnecting via stop() @@ -416,8 +424,9 @@ impl WslBridge { let app_clone = app.clone(); let stats_clone = self.stats.clone(); let conv_id = self.conversation_id.clone(); + let received_init_clone = self.received_init.clone(); thread::spawn(move || { - handle_stdout(stdout, app_clone, stats_clone, conv_id); + handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone); }); } @@ -429,12 +438,31 @@ impl WslBridge { }); } + // Emit Connected immediately so the frontend can send the greeting message. + // This is intentionally optimistic — Claude Code buffers stdout until stdin receives + // data on Windows/WSL, so we must send something to stdin first or system:init never + // arrives. The received_init flag below tracks whether init actually arrived. emit_connection_status( &app, ConnectionStatus::Connected, self.conversation_id.clone(), ); + // Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash + // after spawning). After 5 minutes we kill it so the user isn't stuck forever. + // handle_stdout will surface the error when stdout closes after the kill. + let process_watchdog = self.process.clone(); + let received_init_watchdog = self.received_init.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_secs(60)); + if !received_init_watchdog.load(Ordering::SeqCst) { + if let Some(mut proc) = process_watchdog.lock().take() { + let _ = proc.kill(); + let _ = proc.wait(); + } + } + }); + Ok(()) } @@ -513,7 +541,10 @@ impl WslBridge { // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // we have to kill the process. This is the only reliable way to stop it. // See: https://github.com/anthropics/claude-code/issues/3455 - if let Some(mut process) = self.process.take() { + // Extract the process first so the MutexGuard is dropped before we mutably + // borrow `self` again via estimate_interrupted_request_cost. + let maybe_process = self.process.lock().take(); + if let Some(mut process) = maybe_process { // Estimate cost for interrupted request before killing self.estimate_interrupted_request_cost(app); @@ -643,7 +674,7 @@ impl WslBridge { } pub fn stop(&mut self, app: &AppHandle) { - if let Some(mut process) = self.process.take() { + if let Some(mut process) = self.process.lock().take() { let _ = process.kill(); let _ = process.wait(); } @@ -674,7 +705,7 @@ impl WslBridge { } pub fn is_running(&self) -> bool { - self.process.is_some() + self.process.lock().is_some() } pub fn get_working_directory(&self) -> &str { @@ -697,13 +728,16 @@ fn handle_stdout( app: AppHandle, stats: Arc>, conversation_id: Option, + received_init: Arc, ) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) { + if let Err(e) = + process_json_line(&line, &app, &stats, &conversation_id, &received_init) + { tracing::error!("Error processing line: {}", e); } } @@ -715,6 +749,22 @@ fn handle_stdout( } } + // If stdout closed before system:init arrived the process exited without initialising. + // Emit an error line so the user understands why the connection failed. + if !received_init.load(Ordering::SeqCst) { + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(), + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } + emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); } @@ -919,6 +969,7 @@ fn process_json_line( app: &AppHandle, stats: &Arc>, conversation_id: &Option, + received_init: &Arc, ) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; @@ -931,6 +982,9 @@ fn process_json_line( .. } => { if subtype == "init" { + // Mark as initialised so the watchdog knows the process is healthy. + received_init.store(true, Ordering::SeqCst); + if let Some(id) = session_id { let _ = app.emit( "claude:session", -- 2.52.0 From 2b371bf9af5684a9d1f38475bab0058abc0f6695 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 20:24:18 -0800 Subject: [PATCH 08/12] fix: initialise unread counts on mount to prevent false unread dots (#164) --- src/lib/components/ConversationTabs.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/components/ConversationTabs.svelte b/src/lib/components/ConversationTabs.svelte index 75fe9e1..d45a4b8 100644 --- a/src/lib/components/ConversationTabs.svelte +++ b/src/lib/components/ConversationTabs.svelte @@ -216,6 +216,14 @@ // Keyboard shortcuts onMount(() => { + // Initialise all conversations as seen on mount so that remounting + // this component (e.g. after closing the file editor) doesn't falsely + // mark existing messages as unread. + for (const [id, conversation] of $conversations) { + lastSeenMessageCount.set(id, conversation.terminalLines.length); + } + lastSeenMessageCount = lastSeenMessageCount; + function handleGlobalKeydown(event: KeyboardEvent) { // Ctrl/Cmd + T: New tab if ((event.ctrlKey || event.metaKey) && event.key === "t") { -- 2.52.0 From dfe98be11742f7e70df4e74774d3bf8f3e9af311 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 20:32:26 -0800 Subject: [PATCH 09/12] chore: verify stream-json handles 50K tool result threshold (#162) Add explicit tests documenting that the parser gracefully handles both large inline tool results (>50K chars) and null content from persisted results introduced in Claude CLI v2.1.51. --- src-tauri/src/wsl_bridge.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index b8b4fe1..8f3a4dd 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -2412,4 +2412,29 @@ mod tests { let content = serde_json::json!([]); assert_eq!(extract_tool_result_text(&content), None); } + + // Verify the 50K tool result persistence threshold (CLI v2.1.51+). + // Results > 50K chars are now persisted to disk; the stream may send null + // or a large inline string. Both must be handled without panicking. + #[test] + fn test_extract_tool_result_text_large_content_above_50k_threshold() { + let large_text = "x".repeat(60_000); + let content = serde_json::Value::String(large_text.clone()); + assert_eq!(extract_tool_result_text(&content), Some(large_text)); + } + + #[test] + fn test_tool_result_deserializes_with_null_content() { + let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#; + let block: ContentBlock = serde_json::from_str(json).unwrap(); + if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block { + assert_eq!(tool_use_id, "toolu_abc"); + assert!(content.is_null()); + assert_eq!(is_error, None); + // Persisted-to-disk results produce null content → no preview shown + assert_eq!(extract_tool_result_text(&content), None); + } else { + panic!("Expected ToolResult variant"); + } + } } -- 2.52.0 From 85520bdea6076880ff6076594b6bfe97ee96cf73 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 25 Feb 2026 20:55:29 -0800 Subject: [PATCH 10/12] chore: clean up build warnings in frontend components - Remove unused .animate-spin CSS from PluginManagementPanel and McpManagementPanel - Replace deprecated with Svelte 5 dynamic component syntax - Add for/id associations to MCP add-server form labels - Change display-only