From 333ba3dd1c3fe21602c1b3e98d62a499c47bfcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Thu, 4 Jun 2026 19:52:43 +0800 Subject: [PATCH 01/23] =?UTF-8?q?feat(coding-agent):=20=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=20Agent=20=E5=BC=95=E6=93=8E=20+=20Claude=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=EF=BC=88=E6=97=A0=E5=A4=B4=20Claude=20Code=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #579 骨架的首个垂直切片(UCW 版):只接 Claude Code、放行 + 护栏。 后端 coding_agent/ 模块(19 单测全绿,已对真实 claude v2.1.161 验证链路): - args: claude -p 无头流式 argv 构造 - stream: stream-json 逐行解析为 CodingAgentEvent - guard: 高风险命令分类 + acceptEdits + deny 清单护栏 settings - detect: 解析 claude --version / mcp list(computer use 检测口径) - mod: tokio 异步运行器(超时 / 取消 / stderr 排空)+ git stash create 快照 - commands: detect / run_test(护栏流式)/ cancel / command_risk,注册入 lib.rs 前端 Claude 控制台(设置 → 高级): - 检测面板 + 终端观感流式输出 + 用量;权限模式 / 工作目录 / 可执行路径 - ipc 封装 + 类型 + 5 语言 i18n;控制台测试默认 sonnet 控成本 未含(后续阶段):语音双热键 + coordinator 接线 + 面板 + prefs 持久化。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src-tauri/src/coding_agent/args.rs | 218 ++++++++++++++ .../src-tauri/src/coding_agent/commands.rs | 177 +++++++++++ .../app/src-tauri/src/coding_agent/detect.rs | 130 ++++++++ .../app/src-tauri/src/coding_agent/guard.rs | 117 ++++++++ .../app/src-tauri/src/coding_agent/mod.rs | 244 +++++++++++++++ .../app/src-tauri/src/coding_agent/stream.rs | 166 +++++++++++ openless-all/app/src-tauri/src/lib.rs | 5 + openless-all/app/src/i18n/en.ts | 36 +++ openless-all/app/src/i18n/ja.ts | 36 +++ openless-all/app/src/i18n/ko.ts | 36 +++ openless-all/app/src/i18n/zh-CN.ts | 36 +++ openless-all/app/src/i18n/zh-TW.ts | 36 +++ openless-all/app/src/lib/ipc.ts | 73 +++++ .../pages/settings/ClaudeConsoleSection.tsx | 278 ++++++++++++++++++ openless-all/app/src/pages/settings/tabs.tsx | 2 + 15 files changed, 1590 insertions(+) create mode 100644 openless-all/app/src-tauri/src/coding_agent/args.rs create mode 100644 openless-all/app/src-tauri/src/coding_agent/commands.rs create mode 100644 openless-all/app/src-tauri/src/coding_agent/detect.rs create mode 100644 openless-all/app/src-tauri/src/coding_agent/guard.rs create mode 100644 openless-all/app/src-tauri/src/coding_agent/mod.rs create mode 100644 openless-all/app/src-tauri/src/coding_agent/stream.rs create mode 100644 openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx diff --git a/openless-all/app/src-tauri/src/coding_agent/args.rs b/openless-all/app/src-tauri/src/coding_agent/args.rs new file mode 100644 index 00000000..4da99cf8 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/args.rs @@ -0,0 +1,218 @@ +//! 无头 Claude Code(`claude -p`)调用参数构造。 +//! +//! 纯逻辑:把一个 [`CodingAgentRequest`] 翻译成 `claude` 的命令行参数列表。 +//! prompt 本身**不**进 argv(避免出现在进程列表里泄露),由运行器写进 stdin。 + +use std::path::PathBuf; + +/// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CodingAgentPermissionMode { + /// 只读/计划模式:不改文件。 + Plan, + /// 默认:每个动作都要确认(无头下等于大多被拒,少用)。 + Default, + /// 放行可恢复的编辑/动作(本项目「放行 + 护栏」的默认)。 + AcceptEdits, + /// 跳过所有权限检查——仅高级区,绝不做默认。 + BypassPermissions, +} + +impl CodingAgentPermissionMode { + /// 传给 `--permission-mode` 的字符串。 + pub fn as_cli_arg(self) -> &'static str { + match self { + Self::Plan => "plan", + Self::Default => "default", + Self::AcceptEdits => "acceptEdits", + Self::BypassPermissions => "bypassPermissions", + } + } +} + +impl Default for CodingAgentPermissionMode { + fn default() -> Self { + Self::AcceptEdits + } +} + +/// 一次无头 agent 运行的完整请求。 +#[derive(Debug, Clone)] +pub struct CodingAgentRequest { + /// 会话标识,用于丢弃迟到事件。 + pub session_id: String, + /// 最终发给 Claude 的指令(写入 stdin,不进 argv)。 + pub prompt: String, + /// 工作目录;同时作为 `--add-dir` 限定文件作用域。 + pub cwd: Option, + pub model: Option, + pub fallback_model: Option, + pub permission_mode: CodingAgentPermissionMode, + pub allowed_tools: Vec, + pub disallowed_tools: Vec, + /// 单次运行成本硬上限(`--max-budget-usd`)。 + pub max_budget_usd: Option, + /// 运行超时(秒)。 + pub timeout_secs: u64, + /// 额外系统提示词(`--append-system-prompt`)。 + pub extra_system_prompt: Option, + /// 护栏 settings JSON 文件路径(`--settings`)。 + pub settings_json_path: Option, + /// 是否保留会话(false 时加 `--no-session-persistence`,快取用走 false 更快)。 + pub session_persistence: bool, +} + +impl CodingAgentRequest { + /// 最小化构造:只给会话 id 和 prompt,其余取保守默认。 + pub fn new(session_id: impl Into, prompt: impl Into) -> Self { + Self { + session_id: session_id.into(), + prompt: prompt.into(), + cwd: None, + model: None, + fallback_model: None, + permission_mode: CodingAgentPermissionMode::default(), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + max_budget_usd: None, + timeout_secs: 300, + extra_system_prompt: None, + settings_json_path: None, + session_persistence: true, + } + } +} + +/// 构造 `claude` 的命令行参数(不含可执行文件本身,也不含 prompt)。 +/// +/// 固定使用无头流式:`-p --output-format stream-json --verbose --include-partial-messages`, +/// 这样前端能拿到逐字 delta。 +pub fn build_claude_args(req: &CodingAgentRequest) -> Vec { + let mut args: Vec = vec![ + "-p".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--permission-mode".into(), + req.permission_mode.as_cli_arg().into(), + ]; + + if let Some(model) = &req.model { + args.push("--model".into()); + args.push(model.clone()); + } + if let Some(fm) = &req.fallback_model { + args.push("--fallback-model".into()); + args.push(fm.clone()); + } + if let Some(cwd) = &req.cwd { + args.push("--add-dir".into()); + args.push(cwd.to_string_lossy().into_owned()); + } + if !req.allowed_tools.is_empty() { + args.push("--allowedTools".into()); + args.push(req.allowed_tools.join(",")); + } + if !req.disallowed_tools.is_empty() { + args.push("--disallowedTools".into()); + args.push(req.disallowed_tools.join(",")); + } + if let Some(budget) = req.max_budget_usd { + args.push("--max-budget-usd".into()); + args.push(format!("{budget}")); + } + if let Some(path) = &req.settings_json_path { + args.push("--settings".into()); + args.push(path.to_string_lossy().into_owned()); + } + if let Some(sp) = &req.extra_system_prompt { + args.push("--append-system-prompt".into()); + args.push(sp.clone()); + } + if !req.session_persistence { + args.push("--no-session-persistence".into()); + } + + args +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()) + } + + #[test] + fn default_args_are_headless_streaming() { + let req = CodingAgentRequest::new("s1", "hello"); + let args = build_claude_args(&req); + assert!(args.contains(&"-p".to_string())); + assert_eq!(arg_value(&args, "--output-format"), Some("stream-json")); + assert!(args.contains(&"--verbose".to_string())); + assert!(args.contains(&"--include-partial-messages".to_string())); + // prompt 不能出现在 argv 里 + assert!(!args.iter().any(|a| a.contains("hello"))); + } + + #[test] + fn permission_mode_maps_to_cli_string() { + assert_eq!(CodingAgentPermissionMode::Plan.as_cli_arg(), "plan"); + assert_eq!(CodingAgentPermissionMode::AcceptEdits.as_cli_arg(), "acceptEdits"); + assert_eq!( + CodingAgentPermissionMode::BypassPermissions.as_cli_arg(), + "bypassPermissions" + ); + let mut req = CodingAgentRequest::new("s", "p"); + req.permission_mode = CodingAgentPermissionMode::Plan; + assert_eq!(arg_value(&build_claude_args(&req), "--permission-mode"), Some("plan")); + } + + #[test] + fn default_permission_mode_is_accept_edits() { + assert_eq!( + CodingAgentPermissionMode::default(), + CodingAgentPermissionMode::AcceptEdits + ); + } + + #[test] + fn optional_flags_are_emitted_when_set() { + let mut req = CodingAgentRequest::new("s", "p"); + req.model = Some("sonnet".into()); + req.fallback_model = Some("haiku".into()); + req.max_budget_usd = Some(0.5); + req.cwd = Some(PathBuf::from("/tmp/work")); + req.allowed_tools = vec!["Bash(git *)".into(), "Edit".into()]; + req.disallowed_tools = vec!["Bash(rm -rf:*)".into()]; + req.settings_json_path = Some(PathBuf::from("/tmp/guard.json")); + req.extra_system_prompt = Some("be terse".into()); + req.session_persistence = false; + + let args = build_claude_args(&req); + assert_eq!(arg_value(&args, "--model"), Some("sonnet")); + assert_eq!(arg_value(&args, "--fallback-model"), Some("haiku")); + assert_eq!(arg_value(&args, "--max-budget-usd"), Some("0.5")); + assert_eq!(arg_value(&args, "--add-dir"), Some("/tmp/work")); + assert_eq!(arg_value(&args, "--allowedTools"), Some("Bash(git *),Edit")); + assert_eq!(arg_value(&args, "--disallowedTools"), Some("Bash(rm -rf:*)")); + assert_eq!(arg_value(&args, "--settings"), Some("/tmp/guard.json")); + assert_eq!(arg_value(&args, "--append-system-prompt"), Some("be terse")); + assert!(args.contains(&"--no-session-persistence".to_string())); + } + + #[test] + fn optional_flags_absent_by_default() { + let req = CodingAgentRequest::new("s", "p"); + let args = build_claude_args(&req); + assert!(arg_value(&args, "--model").is_none()); + assert!(arg_value(&args, "--max-budget-usd").is_none()); + assert!(!args.contains(&"--no-session-persistence".to_string())); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/commands.rs b/openless-all/app/src-tauri/src/coding_agent/commands.rs new file mode 100644 index 00000000..f7c1214a --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/commands.rs @@ -0,0 +1,177 @@ +//! 「Claude 控制台」用到的 Tauri 命令:检测安装 / MCP 列表、护栏化流式测试运行、取消。 +//! +//! 这些命令不碰录音 / coordinator,是「快速 Agent」引擎最小可用的垂直切片。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use super::detect::{has_computer_use_mcp, McpServerStatus}; +use super::guard::build_guard_settings_json; +use super::{ + claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent, CodingAgentPermissionMode, + CodingAgentRequest, +}; + +/// 当前测试运行的取消标志(一次只跑一个)。 +static TEST_CANCEL: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +/// 测试运行计数器,给每次运行一个唯一 session id(避免依赖时间戳)。 +static TEST_COUNTER: Lazy> = Lazy::new(|| Mutex::new(0)); + +fn next_session_id() -> String { + let mut c = TEST_COUNTER.lock(); + *c = c.wrapping_add(1); + format!("console-{}", *c) +} + +fn normalize_exe(exe: Option) -> String { + exe.map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "claude".to_string()) +} + +/// Claude Code 检测结果(回前端,camelCase)。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeDetectionWire { + /// 是否检测到可运行的 claude。 + pub installed: bool, + /// 版本号(如 "2.1.161")。 + pub version: Option, + /// 实际使用的可执行文件名/路径。 + pub exe: String, + /// 已配置的 MCP server 列表(含健康状态)。 + pub mcp_servers: Vec, + /// 是否检测到桌面控制类(computer use)MCP。 + pub has_computer_use: bool, +} + +/// 检测 claude 是否安装、版本、已配置的 MCP server(即「computer use 技能」检测口径)。 +#[tauri::command] +pub async fn coding_agent_detect(exe: Option) -> ClaudeDetectionWire { + let exe = normalize_exe(exe); + let version = detect_claude(&exe).await; + let mcp_servers = if version.is_some() { + claude_mcp_list(&exe).await + } else { + Vec::new() + }; + let has_computer_use = has_computer_use_mcp(&mcp_servers); + ClaudeDetectionWire { + installed: version.is_some(), + version, + exe, + mcp_servers, + has_computer_use, + } +} + +/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。 +/// +/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限; +/// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。 +#[tauri::command] +pub async fn coding_agent_run_test( + app: AppHandle, + prompt: String, + exe: Option, + permission_mode: Option, + workdir: Option, + model: Option, + max_budget_usd: Option, +) -> Result<(), String> { + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + return Err("指令为空".into()); + } + let exe = normalize_exe(exe); + let mode = permission_mode.unwrap_or_default(); + + let cwd = workdir + .map(|w| w.trim().to_string()) + .filter(|w| !w.is_empty()) + .map(std::path::PathBuf::from); + + // 运行前 git 快照(仅当是 git 仓库;非仓库返回 None,无副作用)。 + if let Some(dir) = &cwd { + if let Some(sha) = create_git_snapshot(dir) { + log::info!("[coding-agent] 运行前已生成 git 快照 {sha}(git stash apply 可回滚)"); + } + } + + // 写护栏 settings 到临时文件。 + let settings_json = build_guard_settings_json(mode.as_cli_arg(), &[]); + let settings_path = + std::env::temp_dir().join(format!("openless-claude-guard-{}.json", uuid::Uuid::new_v4())); + std::fs::write( + &settings_path, + serde_json::to_vec_pretty(&settings_json).map_err(|e| e.to_string())?, + ) + .map_err(|e| format!("写护栏配置失败: {e}"))?; + + let mut req = CodingAgentRequest::new(next_session_id(), prompt); + req.cwd = cwd; + // 控制台测试默认走 sonnet:比用户默认的 Opus 便宜约一个数量级,足够验证连通与流式。 + req.model = model + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + req.permission_mode = mode; + req.max_budget_usd = max_budget_usd.or(Some(0.5)); + req.timeout_secs = 120; + req.settings_json_path = Some(settings_path.clone()); + req.session_persistence = false; + // 「放行 + 护栏」:允许轻动作与可恢复编辑;高风险由 deny 清单拦截。 + req.allowed_tools = vec![ + "Bash".into(), + "Read".into(), + "Edit".into(), + "Write".into(), + "Glob".into(), + "Grep".into(), + "WebFetch".into(), + "WebSearch".into(), + ]; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(AtomicBool::new(false)); + *TEST_CANCEL.lock() = Some(cancel.clone()); + + let exe_for_task = exe.clone(); + let handle = tauri::async_runtime::spawn(async move { + run_claude_agent(&exe_for_task, req, tx, cancel).await + }); + + // 边收边发:runner 结束会 drop sink,rx 收到 None 退出。 + while let Some(ev) = rx.recv().await { + let _ = app.emit("coding-agent:test", &ev); + } + + let run_result = handle.await; + *TEST_CANCEL.lock() = None; + let _ = std::fs::remove_file(&settings_path); + + match run_result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.to_string()), + Err(join_err) => Err(format!("agent 任务异常: {join_err}")), + } +} + +/// 取消当前正在跑的测试运行。 +#[tauri::command] +pub fn coding_agent_cancel_test() { + if let Some(flag) = TEST_CANCEL.lock().clone() { + flag.store(true, Ordering::Relaxed); + } +} + +/// 本地预检一条命令是否高风险,返回原因(控制台在运行前给用户警示用)。 +#[tauri::command] +pub fn coding_agent_command_risk(command: String) -> Option { + super::guard::is_high_risk_command(&command).map(|r| r.to_string()) +} diff --git a/openless-all/app/src-tauri/src/coding_agent/detect.rs b/openless-all/app/src-tauri/src/coding_agent/detect.rs new file mode 100644 index 00000000..b844d30b --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/detect.rs @@ -0,0 +1,130 @@ +//! 解析 `claude --version` 与 `claude mcp list` 的输出(纯逻辑,便于单测)。 + +/// 从 `claude --version` 输出里提取版本号。 +/// +/// 兼容 `"2.1.161 (Claude Code)"` 与 `"Claude Code version 2.1.161"` 两种排版: +/// 取第一个形如 `x.y.z` 的 token。 +pub fn parse_claude_version(stdout: &str) -> Option { + for raw in stdout.split_whitespace() { + let token = raw.trim_matches(|c: char| !c.is_ascii_digit() && c != '.'); + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + { + return Some(token.to_string()); + } + } + None +} + +/// MCP server 健康状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum McpHealth { + Connected, + Failed, + NeedsAuth, + Unknown, +} + +/// `claude mcp list` 里的一项。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct McpServerStatus { + pub name: String, + pub detail: String, + pub health: McpHealth, +} + +/// 解析 `claude mcp list` 输出。忽略 "Checking..." 等噪声行。 +/// +/// 行格式约为:`: - <✓|✗|!> `。 +pub fn parse_mcp_list(stdout: &str) -> Vec { + let mut out = Vec::new(); + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("Checking") { + continue; + } + let Some((name, rest)) = line.split_once(": ") else { + continue; + }; + // 用最后一个 " - " 分隔 detail 与状态,避免 URL 里的连字符误伤。 + let (detail, status) = match rest.rfind(" - ") { + Some(idx) => (rest[..idx].trim(), rest[idx + 3..].trim()), + None => (rest.trim(), ""), + }; + let health = if status.contains("Connected") { + McpHealth::Connected + } else if status.contains("Failed") { + McpHealth::Failed + } else if status.contains("authentication") || status.contains("Needs") { + McpHealth::NeedsAuth + } else { + McpHealth::Unknown + }; + out.push(McpServerStatus { + name: name.trim().to_string(), + detail: detail.to_string(), + health, + }); + } + out +} + +/// 是否存在桌面控制类(computer use)MCP server。 +/// +/// 这是 OpenLess 对「computer use 技能是否安装」的检测口径:Claude Code 本身无原生 +/// computer use,桌面 GUI 控制只能通过挂载相应 MCP server 获得。 +pub fn has_computer_use_mcp(servers: &[McpServerStatus]) -> bool { + servers.iter().any(|s| { + let n = s.name.to_lowercase(); + n.contains("computer") || n.contains("desktop") || n.contains("screen") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_version_from_both_layouts() { + assert_eq!(parse_claude_version("2.1.161 (Claude Code)").as_deref(), Some("2.1.161")); + assert_eq!( + parse_claude_version("Claude Code version 2.1.161").as_deref(), + Some("2.1.161") + ); + assert_eq!(parse_claude_version("no version here"), None); + } + + #[test] + fn parses_mcp_list_health() { + let stdout = "Checking MCP server health…\n\ +memory: npx -y @modelcontextprotocol/server-memory - ✓ Connected\n\ +railway: npx -y @railway/mcp-server - ✗ Failed to connect\n\ +cloudflare-observability: https://observability.mcp.cloudflare.com/mcp (HTTP) - ! Needs authentication\n"; + let servers = parse_mcp_list(stdout); + assert_eq!(servers.len(), 3); + assert_eq!(servers[0].name, "memory"); + assert_eq!(servers[0].health, McpHealth::Connected); + assert_eq!(servers[1].health, McpHealth::Failed); + assert_eq!(servers[2].name, "cloudflare-observability"); + assert_eq!(servers[2].health, McpHealth::NeedsAuth); + // URL 里的 "-" 不应把状态切错 + assert!(servers[2].detail.contains("observability.mcp.cloudflare.com")); + } + + #[test] + fn detects_computer_use_mcp_by_name() { + let with = vec![McpServerStatus { + name: "computer-use".into(), + detail: String::new(), + health: McpHealth::Connected, + }]; + let without = vec![McpServerStatus { + name: "playwright".into(), + detail: String::new(), + health: McpHealth::Connected, + }]; + assert!(has_computer_use_mcp(&with)); + assert!(!has_computer_use_mcp(&without)); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/guard.rs b/openless-all/app/src-tauri/src/coding_agent/guard.rs new file mode 100644 index 00000000..634524e5 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/guard.rs @@ -0,0 +1,117 @@ +//! 护栏:高风险命令分类 + 生成传给 `claude --settings` 的权限 JSON。 +//! +//! 「放行 + 护栏」策略: +//! - `permissions.defaultMode = acceptEdits`(放行可恢复/轻动作)。 +//! - `permissions.deny` 声明式拦截高风险工具调用(跨平台、稳)。 +//! - 运行级 git 快照由运行器在启动前做(见 `mod.rs::create_git_snapshot`)。 +//! +//! [`is_high_risk_command`] 供「Claude 控制台」等场景对**单条命令**做本地预检/展示用, +//! 与 CLI 侧的 deny 规则互为补充。 + +/// 高风险子串(已小写)→ 原因。命中任一即判为高风险。 +pub const HIGH_RISK_PATTERNS: &[(&str, &str)] = &[ + ("rm -rf", "递归强制删除"), + ("rm -fr", "递归强制删除"), + ("sudo ", "提权执行"), + ("git push --force", "强制推送会覆盖远端历史"), + ("git push -f", "强制推送会覆盖远端历史"), + ("git reset --hard", "硬重置会丢弃未提交改动"), + ("git clean -fd", "强制清理未跟踪文件"), + ("mkfs", "格式化文件系统"), + ("dd if=", "裸盘写入"), + (":(){", "fork 炸弹"), + ("shutdown", "关机"), + ("reboot", "重启"), + ("> /dev/sd", "直接写入块设备"), + ("| sh", "管道执行远程脚本"), + ("|sh", "管道执行远程脚本"), + ("| bash", "管道执行远程脚本"), + ("|bash", "管道执行远程脚本"), + ("chmod -r 777 /", "危险的全局权限修改"), + ("chown -r", "递归改所有权"), +]; + +/// 若命令命中高风险模式,返回原因;否则 `None`。 +pub fn is_high_risk_command(command: &str) -> Option<&'static str> { + let lowered = command.to_lowercase(); + HIGH_RISK_PATTERNS + .iter() + .find(|(pat, _)| lowered.contains(pat)) + .map(|(_, reason)| *reason) +} + +/// CLI `--settings` 默认的 `permissions.deny` 规则(Claude Code 工具说明符语法)。 +pub fn default_deny_rules() -> Vec { + vec![ + "Bash(rm -rf:*)".into(), + "Bash(rm -fr:*)".into(), + "Bash(sudo:*)".into(), + "Bash(git push --force:*)".into(), + "Bash(git push -f:*)".into(), + "Bash(git reset --hard:*)".into(), + "Bash(git clean -fd:*)".into(), + "Bash(mkfs:*)".into(), + "Bash(dd:*)".into(), + "Bash(shutdown:*)".into(), + "Bash(reboot:*)".into(), + "Edit(.env)".into(), + "Edit(.git/**)".into(), + ] +} + +/// 生成护栏 settings JSON。`mode` 为 `--permission-mode` 同名取值; +/// `extra_deny` 追加在默认 deny 之后。 +pub fn build_guard_settings_json(mode: &str, extra_deny: &[String]) -> serde_json::Value { + let mut deny = default_deny_rules(); + deny.extend(extra_deny.iter().cloned()); + serde_json::json!({ + "permissions": { + "defaultMode": mode, + "deny": deny, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_rm_rf_regardless_of_case_and_spacing() { + assert!(is_high_risk_command("rm -rf /tmp/x").is_some()); + assert!(is_high_risk_command("RM -RF /").is_some()); + assert!(is_high_risk_command("sudo apt install").is_some()); + assert!(is_high_risk_command("git push --force origin main").is_some()); + } + + #[test] + fn flags_pipe_to_shell() { + assert!(is_high_risk_command("curl http://x | sh").is_some()); + assert!(is_high_risk_command("wget -qO- x|bash").is_some()); + } + + #[test] + fn allows_ordinary_reversible_commands() { + assert!(is_high_risk_command("ls -la").is_none()); + assert!(is_high_risk_command("git status").is_none()); + assert!(is_high_risk_command("pbcopy < file.txt").is_none()); + assert!(is_high_risk_command("echo hi").is_none()); + } + + #[test] + fn guard_settings_has_accept_edits_and_deny_list() { + let v = build_guard_settings_json("acceptEdits", &[]); + assert_eq!(v["permissions"]["defaultMode"], "acceptEdits"); + let deny = v["permissions"]["deny"].as_array().unwrap(); + assert!(deny.iter().any(|d| d == "Bash(rm -rf:*)")); + assert!(deny.iter().any(|d| d == "Bash(sudo:*)")); + } + + #[test] + fn guard_settings_appends_extra_deny() { + let extra = vec!["Bash(npm publish:*)".to_string()]; + let v = build_guard_settings_json("acceptEdits", &extra); + let deny = v["permissions"]["deny"].as_array().unwrap(); + assert!(deny.iter().any(|d| d == "Bash(npm publish:*)")); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/mod.rs b/openless-all/app/src-tauri/src/coding_agent/mod.rs new file mode 100644 index 00000000..82790ea8 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/mod.rs @@ -0,0 +1,244 @@ +//! 无头 Claude Code 调用子系统(「快速 Agent」后端)。 +//! +//! - [`args`]:`claude -p` 参数构造。 +//! - [`stream`]:stream-json 输出解析为 [`stream::CodingAgentEvent`]。 +//! - [`guard`]:高风险命令分类 + `--settings` 护栏 JSON。 +//! - [`detect`]:解析 `claude --version` / `claude mcp list`。 +//! +//! 本模块只负责「跑无头 Claude 并把事件抛出来」,不碰录音 / ASR / 前端—— +//! 那些由 coordinator 串联(镜像现有 QA 链路)。 + +pub mod args; +pub mod commands; +pub mod detect; +pub mod guard; +pub mod stream; + +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +pub use args::{build_claude_args, CodingAgentPermissionMode, CodingAgentRequest}; +pub use detect::McpServerStatus; +pub use stream::{parse_stream_json_line, CodingAgentEvent}; + +/// 运行器把事件投递到这个 sink(coordinator / 命令层再转成 Tauri event)。 +pub type CodingAgentEventSink = tokio::sync::mpsc::UnboundedSender; + +#[derive(Debug, thiserror::Error)] +pub enum CodingAgentError { + #[error("找不到可执行文件: {0}")] + ExecutableNotFound(String), + #[error("启动 agent 进程失败: {0}")] + Spawn(String), + #[error("agent 进程异常退出 (code={0:?})")] + ProcessExit(Option), + #[error("agent 运行超时 ({0}s)")] + Timeout(u64), + #[error("已取消")] + Cancelled, + #[error("IO 错误: {0}")] + Io(String), +} + +/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境, +/// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。 +fn augment_env(cmd: &mut Command) { + let mut path = std::env::var("PATH").unwrap_or_default(); + if let Some(home_os) = std::env::var_os("HOME") { + let home = home_os.to_string_lossy().to_string(); + let extras = [ + format!("{home}/.local/bin"), + "/opt/homebrew/bin".to_string(), + "/usr/local/bin".to_string(), + ]; + for extra in extras { + if !path.split(':').any(|p| p == extra) { + path = if path.is_empty() { + extra + } else { + format!("{extra}:{path}") + }; + } + } + cmd.env("HOME", home); + } + cmd.env("PATH", path); +} + +fn augmented_command(exe: &str) -> Command { + let mut cmd = Command::new(exe); + augment_env(&mut cmd); + cmd +} + +/// `git stash create`:生成一个表示当前工作区的提交对象,**不改动工作区、也不进 stash 列表**。 +/// 返回该快照的 commit SHA,供出问题时 `git stash apply ` 回滚。无改动时返回 `None`。 +pub fn create_git_snapshot(cwd: &Path) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["stash", "create", "openless-agent-pre-run"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if sha.is_empty() { + None + } else { + Some(sha) + } +} + +/// 探测 `claude` 版本(`None` 表示未安装或无法运行)。 +pub async fn detect_claude(exe: &str) -> Option { + let out = augmented_command(exe).arg("--version").output().await.ok()?; + if !out.status.success() { + return None; + } + detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout)) +} + +/// 列出 Claude Code 已配置的 MCP server(含健康状态)。 +pub async fn claude_mcp_list(exe: &str) -> Vec { + match augmented_command(exe).args(["mcp", "list"]).output().await { + Ok(out) => detect::parse_mcp_list(&String::from_utf8_lossy(&out.stdout)), + Err(_) => Vec::new(), + } +} + +async fn wait_cancel(cancel: &Arc) { + loop { + if cancel.load(Ordering::Relaxed) { + return; + } + tokio::time::sleep(Duration::from_millis(150)).await; + } +} + +/// 无头跑一次 Claude:写 prompt 到 stdin,逐行解析 stream-json,把事件投到 `sink`。 +/// 支持取消(`cancel` 置 true)与超时(`req.timeout_secs`),两者都会 kill 子进程。 +pub async fn run_claude_agent( + exe: &str, + req: CodingAgentRequest, + sink: CodingAgentEventSink, + cancel: Arc, +) -> Result<(), CodingAgentError> { + let args = build_claude_args(&req); + let mut cmd = augmented_command(exe); + cmd.args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = &req.cwd { + cmd.current_dir(cwd); + } + + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CodingAgentError::ExecutableNotFound(exe.to_string()) + } else { + CodingAgentError::Spawn(e.to_string()) + } + })?; + + // 写入 prompt 后立即关闭 stdin,触发 claude 开始处理。 + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(req.prompt.as_bytes()).await; + let _ = stdin.shutdown().await; + } + + // 后台排空 stderr,避免管道写满导致子进程阻塞;出错时用作摘要。 + let stderr_task = child.stderr.take().map(|s| { + tokio::spawn(async move { + let mut buf = String::new(); + let _ = BufReader::new(s).read_to_string(&mut buf).await; + buf + }) + }); + + let _ = sink.send(CodingAgentEvent::Started { + session_id: req.session_id.clone(), + }); + + let stdout = child + .stdout + .take() + .ok_or_else(|| CodingAgentError::Io("子进程无 stdout".into()))?; + let mut lines = BufReader::new(stdout).lines(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(req.timeout_secs.max(1)); + let mut got_terminal = false; + let mut outcome: Result<(), CodingAgentError> = Ok(()); + + loop { + tokio::select! { + biased; + _ = wait_cancel(&cancel) => { + let _ = child.start_kill(); + let _ = sink.send(CodingAgentEvent::Cancelled { session_id: req.session_id.clone() }); + got_terminal = true; + outcome = Err(CodingAgentError::Cancelled); + break; + } + _ = tokio::time::sleep_until(deadline) => { + let _ = child.start_kill(); + let _ = sink.send(CodingAgentEvent::Error { + session_id: req.session_id.clone(), + message: format!("运行超时({}s)", req.timeout_secs), + }); + got_terminal = true; + outcome = Err(CodingAgentError::Timeout(req.timeout_secs)); + break; + } + line = lines.next_line() => { + match line { + Ok(Some(l)) => { + if let Some(ev) = parse_stream_json_line(&req.session_id, &l) { + if matches!(ev, CodingAgentEvent::Completed { .. } | CodingAgentEvent::Error { .. }) { + got_terminal = true; + } + let _ = sink.send(ev); + } + } + Ok(None) => break, // EOF + Err(e) => { + outcome = Err(CodingAgentError::Io(e.to_string())); + break; + } + } + } + } + } + + let status = child.wait().await.map_err(|e| CodingAgentError::Io(e.to_string()))?; + if !status.success() && outcome.is_ok() { + // 进程非 0 退出且我们还没判终局:补一条 Error。 + if !got_terminal { + let stderr = match stderr_task { + Some(t) => t.await.unwrap_or_default(), + None => String::new(), + }; + let summary = stderr.lines().last().unwrap_or("").trim().to_string(); + let _ = sink.send(CodingAgentEvent::Error { + session_id: req.session_id.clone(), + message: if summary.is_empty() { + format!("agent 异常退出 (code={:?})", status.code()) + } else { + summary + }, + }); + } + return Err(CodingAgentError::ProcessExit(status.code())); + } + + outcome +} diff --git a/openless-all/app/src-tauri/src/coding_agent/stream.rs b/openless-all/app/src-tauri/src/coding_agent/stream.rs new file mode 100644 index 00000000..d1349eb0 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/stream.rs @@ -0,0 +1,166 @@ +//! 解析 `claude -p --output-format stream-json` 的逐行 JSON 输出。 +//! +//! 关注的几类行(其余忽略): +//! - `stream_event` → `content_block_delta` → `text_delta`:逐字增量。 +//! - `assistant` 消息里的 `tool_use` 块:工具调用提示。 +//! - `result`:终局,带 `result` 文本、`total_cost_usd`、`duration_ms`、`is_error`。 + +/// 转发给前端的 agent 事件。`kind` 作为 tag,前端按 `kind` 分发。 +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CodingAgentEvent { + /// 进程已启动。 + Started { session_id: String }, + /// 逐字增量文本。 + Delta { session_id: String, text: String }, + /// agent 触发了某个工具(如 Bash / Edit)。 + ToolUse { session_id: String, name: String }, + /// 运行完成的最终结果。 + Completed { + session_id: String, + text: String, + cost_usd: Option, + duration_ms: Option, + }, + /// 用户取消。 + Cancelled { session_id: String }, + /// 运行出错(超时、进程异常、解析失败等)。 + Error { session_id: String, message: String }, +} + +/// 解析一行 stream-json。无关行返回 `None`(防御式:解析失败也返回 `None`,不 panic)。 +pub fn parse_stream_json_line(session_id: &str, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let v: serde_json::Value = serde_json::from_str(line).ok()?; + match v.get("type")?.as_str()? { + "stream_event" => { + let event = v.get("event")?; + if event.get("type")?.as_str()? != "content_block_delta" { + return None; + } + let delta = event.get("delta")?; + if delta.get("type")?.as_str()? != "text_delta" { + return None; + } + let text = delta.get("text")?.as_str()?.to_string(); + Some(CodingAgentEvent::Delta { + session_id: session_id.to_string(), + text, + }) + } + "assistant" => { + let content = v.get("message")?.get("content")?.as_array()?; + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { + if let Some(name) = block.get("name").and_then(|n| n.as_str()) { + return Some(CodingAgentEvent::ToolUse { + session_id: session_id.to_string(), + name: name.to_string(), + }); + } + } + } + None + } + "result" => { + let is_error = v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false); + let text = v + .get("result") + .and_then(|r| r.as_str()) + .unwrap_or_default() + .to_string(); + if is_error { + Some(CodingAgentEvent::Error { + session_id: session_id.to_string(), + message: if text.is_empty() { + "agent 返回错误".to_string() + } else { + text + }, + }) + } else { + Some(CodingAgentEvent::Completed { + session_id: session_id.to_string(), + text, + cost_usd: v.get("total_cost_usd").and_then(|c| c.as_f64()), + duration_ms: v.get("duration_ms").and_then(|d| d.as_u64()), + }) + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_text_delta() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}}}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Delta { + session_id: "s1".into(), + text: "你好".into() + }) + ); + } + + #[test] + fn ignores_non_text_deltas() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{"}}}"#; + assert_eq!(parse_stream_json_line("s1", line), None); + } + + #[test] + fn parses_tool_use() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{}}]}}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::ToolUse { + session_id: "s1".into(), + name: "Bash".into() + }) + ); + } + + #[test] + fn parses_successful_result_with_cost() { + let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.0123,"duration_ms":1500,"session_id":"abc"}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Completed { + session_id: "s1".into(), + text: "done".into(), + cost_usd: Some(0.0123), + duration_ms: Some(1500), + }) + ); + } + + #[test] + fn parses_error_result() { + let line = r#"{"type":"result","is_error":true,"result":"boom"}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Error { + session_id: "s1".into(), + message: "boom".into() + }) + ); + } + + #[test] + fn ignores_system_init_and_garbage() { + assert_eq!( + parse_stream_json_line("s1", r#"{"type":"system","subtype":"init"}"#), + None + ); + assert_eq!(parse_stream_json_line("s1", "not json"), None); + assert_eq!(parse_stream_json_line("s1", ""), None); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 5679745e..0a273df3 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod asr; mod audio_mute; mod cli; +mod coding_agent; mod combo_hotkey; mod commands; mod coordinator; @@ -375,6 +376,10 @@ pub fn run() { commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, + coding_agent::commands::coding_agent_detect, + coding_agent::commands::coding_agent_run_test, + coding_agent::commands::coding_agent_cancel_test, + coding_agent::commands::coding_agent_command_risk, commands::handle_window_hotkey_event, #[cfg(debug_assertions)] commands::inject_hotkey_click_for_dev, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2957b4db..4fed84f2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -499,6 +499,42 @@ export const en: typeof zhCN = { title: 'Data storage', desc: 'Conversation history and context kept on this device.', }, + codingConsole: { + title: 'Claude Console', + desc: 'Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.', + guardNote: 'Reversible actions are allowed by default; high-risk commands (rm -rf, sudo, force push) are blocked; if the working dir is a git repo, a snapshot is taken before each run for rollback.', + status: 'Status', + detect: 'Detect', + detecting: 'Detecting…', + installed: 'Claude detected', + notInstalled: 'claude not found', + notInstalledHint: 'Install Claude Code first (see docs.anthropic.com/claude-code), or enter the full path to its executable below.', + mcpServers: '{{count}} MCP server(s) configured', + computerUsePresent: 'Desktop-control (computer use) MCP configured', + computerUseAbsent: 'No desktop-control MCP (light actions like copy/paste work via Bash — not required)', + exePath: 'Executable', + workdir: 'Working directory', + workdirDesc: 'Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.', + workdirPlaceholder: 'Empty = run in a temp dir', + permissionMode: 'Permission mode', + mode: { + acceptEdits: 'Allow (reversible)', + plan: 'Read-only / plan', + default: 'Default (ask each)', + bypassPermissions: 'Full bypass (risky)', + }, + promptPlaceholder: 'Ask Claude to do something, e.g. list files in the current directory', + run: 'Run', + running: 'Running…', + cancel: 'Cancel', + clear: 'Clear', + riskWarn: 'High-risk intent detected: {{reason}}. The guardrail blocks high-risk commands at execution time.', + toolUse: 'tool {{name}}', + done: 'Done', + doneCost: 'Done · cost ${{cost}}', + cancelled: 'Cancelled', + outputPlaceholder: 'Output streams here…', + }, debug: { title: 'Debug tools', desc: 'For troubleshooting recognition issues; off by default.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 4ca0a390..4467b4db 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -501,6 +501,42 @@ export const ja: typeof zhCN = { title: 'データ保存', desc: 'この端末に保存される会話履歴とコンテキスト。', }, + codingConsole: { + title: 'Claude コンソール', + desc: 'ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。', + guardNote: '復元可能な操作はデフォルトで許可。rm -rf / sudo / 強制プッシュなどの高リスクコマンドはブロック。作業ディレクトリが git リポジトリなら実行前にスナップショットを作成し巻き戻し可能。', + status: '状態', + detect: '検出', + detecting: '検出中…', + installed: 'Claude を検出', + notInstalled: 'claude が見つかりません', + notInstalledHint: 'まず Claude Code をインストールしてください(docs.anthropic.com/claude-code 参照)。または下に実行ファイルのフルパスを入力してください。', + mcpServers: 'MCP サーバー {{count}} 件', + computerUsePresent: 'デスクトップ操作(computer use)MCP を検出', + computerUseAbsent: 'デスクトップ操作 MCP なし(コピー / 貼り付けなどの軽い操作は Bash で可能、不要)', + exePath: '実行ファイル', + workdir: '作業ディレクトリ', + workdirDesc: '任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。', + workdirPlaceholder: '空欄なら一時ディレクトリで実行', + permissionMode: '権限モード', + mode: { + acceptEdits: '許可(復元可能)', + plan: '読み取り専用 / 計画', + default: 'デフォルト(都度確認)', + bypassPermissions: '完全許可(高リスク)', + }, + promptPlaceholder: 'Claude に指示、例:カレントディレクトリのファイル名を一覧表示', + run: '実行', + running: '実行中…', + cancel: 'キャンセル', + clear: 'クリア', + riskWarn: '高リスクの意図を検出:{{reason}}。ガードレールが実行時に高リスクコマンドをブロックします。', + toolUse: 'ツール {{name}}', + done: '完了', + doneCost: '完了 · コスト ${{cost}}', + cancelled: 'キャンセル済み', + outputPlaceholder: '出力はここにストリーミング表示されます…', + }, debug: { title: 'デバッグツール', desc: '認識の問題を調査するときに使用。通常はオフのままで構いません。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0b849849..ccfe1017 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -501,6 +501,42 @@ export const ko: typeof zhCN = { title: '데이터 저장', desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', }, + codingConsole: { + title: 'Claude 콘솔', + desc: '로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.', + guardNote: '복구 가능한 작업은 기본 허용; rm -rf / sudo / 강제 푸시 등 고위험 명령은 차단; 작업 디렉터리가 git 저장소이면 실행 전 스냅샷을 만들어 되돌릴 수 있습니다.', + status: '상태', + detect: '감지', + detecting: '감지 중…', + installed: 'Claude 감지됨', + notInstalled: 'claude 를 찾을 수 없음', + notInstalledHint: '먼저 Claude Code 를 설치하세요(docs.anthropic.com/claude-code 참고). 또는 아래에 실행 파일 전체 경로를 입력하세요.', + mcpServers: 'MCP 서버 {{count}}개 구성됨', + computerUsePresent: '데스크톱 제어(computer use) MCP 구성됨', + computerUseAbsent: '데스크톱 제어 MCP 없음(복사/붙여넣기 같은 가벼운 작업은 Bash 로 가능, 불필요)', + exePath: '실행 파일', + workdir: '작업 디렉터리', + workdirDesc: '선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.', + workdirPlaceholder: '비우면 임시 디렉터리에서 실행', + permissionMode: '권한 모드', + mode: { + acceptEdits: '허용(복구 가능)', + plan: '읽기 전용 / 계획', + default: '기본(매번 확인)', + bypassPermissions: '완전 허용(위험)', + }, + promptPlaceholder: 'Claude 에게 작업 지시, 예: 현재 디렉터리 파일 목록', + run: '실행', + running: '실행 중…', + cancel: '취소', + clear: '지우기', + riskWarn: '고위험 의도 감지: {{reason}}. 가드레일이 실행 시 고위험 명령을 차단합니다.', + toolUse: '도구 {{name}}', + done: '완료', + doneCost: '완료 · 비용 ${{cost}}', + cancelled: '취소됨', + outputPlaceholder: '출력이 여기에 스트리밍됩니다…', + }, debug: { title: '디버그 도구', desc: '인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ca387876..9ca05a36 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -497,6 +497,42 @@ export const zhCN = { title: '数据存储', desc: '本机保留的历史会话与对话上下文。', }, + codingConsole: { + title: 'Claude 控制台', + desc: '检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。', + guardNote: '默认放行可恢复操作;rm -rf / sudo / 强制推送等高风险命令被拦截;若工作目录是 git 仓库,运行前自动生成快照可回滚。', + status: '状态', + detect: '检测', + detecting: '检测中…', + installed: '已检测到 Claude', + notInstalled: '未检测到 claude', + notInstalledHint: '请先安装 Claude Code(参见 docs.anthropic.com/claude-code),或在下方填写其可执行文件完整路径。', + mcpServers: '已配置 {{count}} 个 MCP 服务', + computerUsePresent: '已配置桌面控制(computer use)MCP', + computerUseAbsent: '未配置桌面控制 MCP(复制/粘贴等轻动作用 Bash 即可,无需此项)', + exePath: '可执行文件', + workdir: '工作目录', + workdirDesc: '可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。', + workdirPlaceholder: '留空则在临时目录运行', + permissionMode: '权限模式', + mode: { + acceptEdits: '放行(可恢复操作)', + plan: '只读 / 计划', + default: '默认(逐项确认)', + bypassPermissions: '完全放行(高风险)', + }, + promptPlaceholder: '让 Claude 做点什么,例如:把当前目录的文件名列出来', + run: '运行', + running: '运行中…', + cancel: '取消', + clear: '清空', + riskWarn: '检测到高风险意图:{{reason}}。护栏会在执行层拦截高风险命令。', + toolUse: '调用工具 {{name}}', + done: '完成', + doneCost: '完成 · 用量 ${{cost}}', + cancelled: '已取消', + outputPlaceholder: '输出会流式显示在这里…', + }, debug: { title: '调试工具', desc: '排查识别问题时使用,平时无需开启。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 043e71b9..6c6d8ccc 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -499,6 +499,42 @@ export const zhTW: typeof zhCN = { title: '資料儲存', desc: '本機保留的歷史會話與對話上下文。', }, + codingConsole: { + title: 'Claude 主控台', + desc: '偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。', + guardNote: '預設放行可復原操作;rm -rf / sudo / 強制推送等高風險指令會被攔截;若工作目錄為 git 儲存庫,執行前自動建立快照可回滾。', + status: '狀態', + detect: '偵測', + detecting: '偵測中…', + installed: '已偵測到 Claude', + notInstalled: '未偵測到 claude', + notInstalledHint: '請先安裝 Claude Code(參見 docs.anthropic.com/claude-code),或在下方填入其執行檔完整路徑。', + mcpServers: '已設定 {{count}} 個 MCP 服務', + computerUsePresent: '已設定桌面控制(computer use)MCP', + computerUseAbsent: '未設定桌面控制 MCP(複製/貼上等輕動作用 Bash 即可,無需此項)', + exePath: '執行檔', + workdir: '工作目錄', + workdirDesc: '選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。', + workdirPlaceholder: '留空則於暫存目錄執行', + permissionMode: '權限模式', + mode: { + acceptEdits: '放行(可復原操作)', + plan: '唯讀 / 計畫', + default: '預設(逐項確認)', + bypassPermissions: '完全放行(高風險)', + }, + promptPlaceholder: '讓 Claude 做點什麼,例如:列出目前目錄的檔名', + run: '執行', + running: '執行中…', + cancel: '取消', + clear: '清空', + riskWarn: '偵測到高風險意圖:{{reason}}。護欄會在執行層攔截高風險指令。', + toolUse: '呼叫工具 {{name}}', + done: '完成', + doneCost: '完成 · 用量 ${{cost}}', + cancelled: '已取消', + outputPlaceholder: '輸出會串流顯示在這裡…', + }, debug: { title: '除錯工具', desc: '排查辨識問題時使用,平時無需開啟。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 842f103f..db5ebcbf 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -1151,6 +1151,79 @@ export async function exportErrorLog( export { isTauri } +// ── Coding Agent / Claude 控制台 ─────────────────────────────────────── +export type CodingAgentPermissionMode = + | "plan" + | "default" + | "acceptEdits" + | "bypassPermissions" + +export type McpHealth = "connected" | "failed" | "needs_auth" | "unknown" + +export interface McpServerStatus { + name: string + detail: string + health: McpHealth +} + +export interface ClaudeDetection { + installed: boolean + version: string | null + exe: string + mcpServers: McpServerStatus[] + hasComputerUse: boolean +} + +/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */ +export type CodingAgentEvent = + | { kind: "started"; session_id: string } + | { kind: "delta"; session_id: string; text: string } + | { kind: "tool_use"; session_id: string; name: string } + | { + kind: "completed" + session_id: string + text: string + cost_usd: number | null + duration_ms: number | null + } + | { kind: "cancelled"; session_id: string } + | { kind: "error"; session_id: string; message: string } + +export function codingAgentDetect(exe?: string): Promise { + return invokeOrMock( + "coding_agent_detect", + { exe }, + () => ({ + installed: false, + version: null, + exe: exe || "claude", + mcpServers: [], + hasComputerUse: false, + }), + ) +} + +export interface CodingAgentRunTestArgs { + prompt: string + exe?: string + permissionMode?: CodingAgentPermissionMode + workdir?: string + model?: string + maxBudgetUsd?: number +} + +export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise { + return invokeOrMock("coding_agent_run_test", { ...args }, () => undefined) +} + +export function codingAgentCancelTest(): Promise { + return invokeOrMock("coding_agent_cancel_test", undefined, () => undefined) +} + +export function codingAgentCommandRisk(command: string): Promise { + return invokeOrMock("coding_agent_command_risk", { command }, () => null) +} + // ── Marketplace (Phase A) ───────────────────────────────────────────── // 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback // 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。 diff --git a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx new file mode 100644 index 00000000..8397dc33 --- /dev/null +++ b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx @@ -0,0 +1,278 @@ +// 高级 → Claude 控制台:检测 claude 安装 / MCP(computer use)状态, +// 并护栏化地无头跑一次 claude、流式查看输出与用量。这是「快速 Agent」引擎的 +// 最小可用垂直切片,不依赖录音 / coordinator。 + +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useTranslation } from 'react-i18next' +import { + codingAgentCancelTest, + codingAgentCommandRisk, + codingAgentDetect, + codingAgentRunTest, + isTauri, + type ClaudeDetection, + type CodingAgentEvent, + type CodingAgentPermissionMode, +} from '../../lib/ipc' +import { Btn, Card } from '../_atoms' +import { SectionDesc, SectionTitle, SettingRow, inputStyle } from './shared' + +const PERMISSION_MODES: CodingAgentPermissionMode[] = [ + 'acceptEdits', + 'plan', + 'default', + 'bypassPermissions', +] + +const consoleStyle: CSSProperties = { + margin: 0, + marginTop: 10, + padding: '12px 14px', + minHeight: 96, + maxHeight: 260, + overflow: 'auto', + borderRadius: 10, + background: '#0f1117', + color: '#d7dbe0', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + fontSize: 12, + lineHeight: 1.6, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', +} + +export function ClaudeConsoleSection() { + const { t } = useTranslation() + const [detection, setDetection] = useState(null) + const [detecting, setDetecting] = useState(false) + const [exe, setExe] = useState('claude') + const [workdir, setWorkdir] = useState('') + const [permMode, setPermMode] = useState('acceptEdits') + const [prompt, setPrompt] = useState('') + const [running, setRunning] = useState(false) + const [output, setOutput] = useState('') + const [summary, setSummary] = useState(null) + const [risk, setRisk] = useState(null) + const outRef = useRef(null) + + async function runDetect() { + setDetecting(true) + try { + setDetection(await codingAgentDetect(exe.trim() || undefined)) + } finally { + setDetecting(false) + } + } + + // 订阅后端流式事件。 + useEffect(() => { + if (!isTauri) return + let unlisten: (() => void) | undefined + let alive = true + void (async () => { + const { listen } = await import('@tauri-apps/api/event') + const un = await listen('coding-agent:test', e => { + const ev = e.payload + switch (ev.kind) { + case 'started': + setOutput('') + setSummary(null) + break + case 'delta': + setOutput(prev => prev + ev.text) + break + case 'tool_use': + setOutput(prev => `${prev}\n· ${t('settings.codingConsole.toolUse', { name: ev.name })}\n`) + break + case 'completed': + setRunning(false) + setSummary( + ev.cost_usd != null + ? t('settings.codingConsole.doneCost', { cost: ev.cost_usd.toFixed(4) }) + : t('settings.codingConsole.done'), + ) + if (ev.text.trim()) setOutput(prev => (prev.trim() ? prev : ev.text)) + break + case 'cancelled': + setRunning(false) + setSummary(t('settings.codingConsole.cancelled')) + break + case 'error': + setRunning(false) + setSummary(`✗ ${ev.message}`) + break + } + }) + if (alive) unlisten = un + else un() + })() + return () => { + alive = false + unlisten?.() + } + }, [t]) + + // 首次自动检测。 + useEffect(() => { + void runDetect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 输出自动滚到底。 + useEffect(() => { + if (outRef.current) outRef.current.scrollTop = outRef.current.scrollHeight + }, [output]) + + const onRun = async () => { + const p = prompt.trim() + if (!p || running) return + setRisk(await codingAgentCommandRisk(p)) + setRunning(true) + setOutput('') + setSummary(null) + try { + await codingAgentRunTest({ + prompt: p, + exe: exe.trim() || undefined, + permissionMode: permMode, + workdir: workdir.trim() || undefined, + maxBudgetUsd: 0.5, + }) + } catch (err) { + setRunning(false) + setSummary(`✗ ${err instanceof Error ? err.message : String(err)}`) + } + } + + const installed = detection?.installed === true + + return ( + + {t('settings.codingConsole.title')} + {t('settings.codingConsole.desc')} + + +
+
+ void runDetect()}> + {detecting ? t('settings.codingConsole.detecting') : t('settings.codingConsole.detect')} + + {detection && ( + + {installed + ? `${t('settings.codingConsole.installed')} · v${detection.version ?? '?'}` + : t('settings.codingConsole.notInstalled')} + + )} +
+ {detection && !installed && ( + + {t('settings.codingConsole.notInstalledHint')} + + )} + {detection && installed && ( + + {t('settings.codingConsole.mcpServers', { count: detection.mcpServers.length })} + {' · '} + {detection.hasComputerUse + ? t('settings.codingConsole.computerUsePresent') + : t('settings.codingConsole.computerUseAbsent')} + + )} +
+
+ + + setExe(e.target.value)} + style={inputStyle} + /> + + + + setWorkdir(e.target.value)} + style={inputStyle} + /> + + + + + + +
+