feat: Claude CLI 2.1.50–2.1.53 audit (#171)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m28s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled

## Summary

This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review.

### New Features
- **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly
- **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active
- **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead)
- **Org UUID in account info** — exposes the org UUID from Claude auth status

### Bug Fixes
- **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164)
- **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166)
- **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165)
- **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169)

### Maintenance
- Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162)
- Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163)
- Expose org UUID from `claude auth status` (Closes #160)
- Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import)
- Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147)
- Run `cargo update` to bring Cargo.lock up to date

### Closes

Closes #160
Closes #162
Closes #163
Closes #164
Closes #165
Closes #166
Closes #167
Closes #168
Closes #169
Closes #81
Closes #82
Closes #83
Closes #84
Closes #85
Closes #86
Closes #87
Closes #90
Closes #91
Closes #93
Closes #94
Closes #95
Closes #96
Closes #97
Closes #98
Closes #99
Closes #101
Closes #141
Closes #142
Closes #143
Closes #145
Closes #146
Closes #147

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #171
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #171.
This commit is contained in:
2026-02-25 22:55:47 -08:00
committed by Naomi Carrigan
parent 1bb7eb4d26
commit b745100bd5
33 changed files with 2094 additions and 1163 deletions
+152 -2
View File
@@ -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
}
}
}
@@ -334,6 +343,121 @@ 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<String>,
pub mcp_servers: Vec<String>,
pub custom_commands: Vec<String>,
}
/// 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<String> = std::collections::BTreeSet::new();
let mut all_mcp_servers: std::collections::BTreeSet<String> = 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<String> = all_hook_types.into_iter().collect();
let mcp_servers: Vec<String> = 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<String> {
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")
.hide_window()
.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<String> = 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<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory
@@ -381,6 +505,7 @@ async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
let output = Command::new("wsl")
.hide_window()
.args([
"-e",
"sh",
@@ -680,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
);
let output = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -742,6 +868,7 @@ async fn read_file_via_wsl(path: &str) -> Result<String, String> {
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))?;
@@ -773,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()
@@ -821,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))?;
@@ -830,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))?;
@@ -870,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))?;
@@ -879,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))?;
@@ -923,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))?;
@@ -933,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))?;
@@ -942,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))?;
@@ -986,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))?;
@@ -996,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))?;
@@ -1005,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))?;
@@ -1050,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))?;
@@ -1060,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))?;
@@ -1069,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))?;
@@ -1246,6 +1387,7 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
"#;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1366,6 +1508,7 @@ pub async fn get_claude_version() -> Result<String, String> {
pub struct ClaudeAuthStatus {
pub is_logged_in: bool,
pub email: Option<String>,
pub org_id: Option<String>,
pub org_name: Option<String>,
pub api_key_source: Option<String>,
pub api_provider: Option<String>,
@@ -1396,6 +1539,11 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
.and_then(|v| v.as_str())
.map(String::from);
let org_id = json
.get("orgId")
.and_then(|v| v.as_str())
.map(String::from);
let org_name = json
.get("orgName")
.and_then(|v| v.as_str())
@@ -1420,6 +1568,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus {
is_logged_in,
email,
org_id,
org_name,
api_key_source,
api_provider,
@@ -1436,6 +1585,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus {
is_logged_in,
email: None,
org_id: None,
org_name: None,
api_key_source: None,
api_provider: None,
+21
View File
@@ -125,6 +125,16 @@ pub struct HikariConfig {
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
// Background image settings
#[serde(default)]
pub background_image_path: Option<String>,
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
}
impl Default for HikariConfig {
@@ -159,6 +169,9 @@ impl Default for HikariConfig {
discord_rpc_enabled: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
}
}
}
@@ -195,6 +208,10 @@ fn default_discord_rpc_enabled() -> bool {
true
}
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
@@ -268,6 +285,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 +320,9 @@ mod tests {
discord_rpc_enabled: true,
use_worktree: true,
disable_1m_context: false,
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
};
let json = serde_json::to_string(&config).unwrap();
+3
View File
@@ -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<String, String> {
let output = Command::new("git")
.hide_window()
.args(args)
.current_dir(working_dir)
.output()
+2
View File
@@ -8,6 +8,7 @@ mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
mod process_ext;
mod quick_actions;
mod sessions;
mod snippets;
@@ -120,6 +121,7 @@ pub fn run() {
get_persisted_stats,
load_saved_achievements,
answer_question,
check_workspace_hooks,
send_windows_notification,
send_simple_notification,
send_windows_toast,
+6
View File
@@ -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<St
pub async fn send_notify_send(title: String, body: String) -> 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("*")
+21
View File
@@ -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
}
}
+4 -1
View File
@@ -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()
+108 -26
View File
@@ -1,17 +1,17 @@
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;
use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
@@ -89,7 +89,7 @@ fn find_claude_binary() -> Option<String> {
// 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() {
@@ -102,52 +102,58 @@ fn find_claude_binary() -> Option<String> {
}
pub struct WslBridge {
process: Option<Child>,
process: Arc<Mutex<Option<Child>>>,
stdin: Option<ChildStdin>,
working_directory: String,
session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
}
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
@@ -224,6 +230,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 +298,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 +309,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 +384,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
};
@@ -396,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()
@@ -413,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);
});
}
@@ -426,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(())
}
@@ -510,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);
@@ -640,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();
}
@@ -671,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 {
@@ -694,13 +728,16 @@ fn handle_stdout(
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
) {
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);
}
}
@@ -712,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);
}
@@ -916,6 +969,7 @@ fn process_json_line(
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
received_init: &Arc<AtomicBool>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
@@ -928,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",
@@ -2059,7 +2116,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 +2135,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();
@@ -2355,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");
}
}
}
+4
View File
@@ -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")