feat(coding-agent): OpenCode CLI 适配器,语音 Agent 可选 OpenCode 后端 (Refs #579) (#638)

* feat(coding-agent): OpenCode CLI 适配器,语音 Agent 可选 OpenCode 后端 (issue #579)

把 OpenCode 当作与 Claude Code 同类的 coding agent CLI 接入,照既有 coding_agent
适配模式(不另造架构),复用同一套 CodingAgentRequest / CodingAgentEvent /
CodingAgentError / 审批护栏链路。

后端:
- coding_agent/opencode.rs(新):detect_opencode(opencode --version)、
  build_opencode_args(opencode run --format json --model --dir
  [--dangerously-skip-permissions],prompt 作为 argv)、parse_opencode_json_line
  (NDJSON:text/tool_use/error;text 块累计,EOF 合成 Completed)、
  run_opencode_agent(取消/超时 kill,护栏经 OPENCODE_CONFIG_CONTENT 注入)。
- coding_agent/args.rs:CodingAgentProvider 枚举(claude-code-cli/opencode-cli,
  未知回落 Claude)+ from_pref/default_exe。
- coding_agent/guard.rs:build_opencode_guard_config —— 把高风险 bash 前缀翻译成
  OpenCode permission.bash deny glob + webfetch deny,审批放行的前缀显式 allow。
- coordinator/dictation_voice_agent: run_less_computer_once 按 coding_agent_provider
  分派 Claude / OpenCode 运行器;两路都 fail-closed(护栏生成失败即中止,不裸跑)。
  审批拦截探测/重跑放行链路 provider 无关,自动复用。
- commands: coding_agent_detect_opencode 命令(已注册 lib.rs)。

前端:
- CodingAgentSection: OpenCode 选项从「即将支持」改为可用 + 选中后探测安装状态并提示。
- ipc: codingAgentDetectOpencode + OpenCodeDetection。
- i18n: opencodeReady / opencodeMissing(5 语言,替换 providerOpenCodeSoon)。

验证:cargo check + cargo test(492 通过)+ npm run build 均通过。

Refs #579
(#579 还含「语音润色模型配置」:prefs 字段已存在但全链路未实现/未接 UI,
转写直接进 agent 未经语音润色模型整理 —— 故用 Refs 不用 Closes。)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(coding-agent): OpenCode 防参数注入 + 可配置 Agent 可执行文件路径 (#638)

注入风险(@H-Chris233 评审):`opencode run` 的 prompt 作为最后位置参数,但之前缺
end-of-options 分隔。以 `-` / `--` 开头的 prompt(语音转写或被 prompt 注入诱导)会被
OpenCode 当作 flag 解析,可绕过护栏(混入 --dangerously-skip-permissions、--dir 改写
工作目录等)。修复:build_opencode_args 末尾追加 `--`,运行器把 prompt 接在其后;新增单测。

可配置路径(@PR-Agent 指出之前 hardcode "opencode"):新增 prefs.coding_agent_exe,
语音 Agent 路径据此取 exe(留空回退默认 claude / opencode),检测命令亦用配置路径;
设置「高级 → Less Computer」新增「可执行文件路径」输入;补 5 语言 i18n。

---------

Co-authored-by: 吕柏青 <lubaiqing9@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-06-24 12:30:12 +08:00
1 parent 0881338e2e
commit e1c1be2942
19 files changed
+797 -71

No files matched your search

@@ -5,6 +5,34 @@
use std::path::PathBuf;
/// 后端 coding agent 提供商,对应 `UserPreferences.coding_agent_provider` 的取值。
/// 未知/缺省一律回落 Claude(既有默认),不破坏现有用户。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodingAgentProvider {
/// Claude Code CLI(`claude`)。默认。
ClaudeCodeCli,
/// OpenCode CLI(`opencode`),issue #579。
OpenCodeCli,
}
impl CodingAgentProvider {
/// 从 prefs 字符串解析。`"opencode-cli"` → OpenCode;其余(含 `"claude-code-cli"`)→ Claude。
pub fn from_pref(s: &str) -> Self {
match s.trim() {
"opencode-cli" => Self::OpenCodeCli,
_ => Self::ClaudeCodeCli,
}
}
/// 该 provider 默认的可执行文件名。
pub fn default_exe(self) -> &'static str {
match self {
Self::ClaudeCodeCli => "claude",
Self::OpenCodeCli => "opencode",
}
}
}
/// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -230,4 +258,31 @@ mod tests {
assert!(arg_value(&args, "--max-budget-usd").is_none());
assert!(!args.contains(&"--no-session-persistence".to_string()));
}
#[test]
fn provider_parses_from_pref_with_claude_fallback() {
assert_eq!(
CodingAgentProvider::from_pref("opencode-cli"),
CodingAgentProvider::OpenCodeCli
);
assert_eq!(
CodingAgentProvider::from_pref("claude-code-cli"),
CodingAgentProvider::ClaudeCodeCli
);
// 未知/空 → 回落 Claude(不破坏现有用户)。
assert_eq!(
CodingAgentProvider::from_pref(""),
CodingAgentProvider::ClaudeCodeCli
);
assert_eq!(
CodingAgentProvider::from_pref("something-else"),
CodingAgentProvider::ClaudeCodeCli
);
}
#[test]
fn provider_default_exe() {
assert_eq!(CodingAgentProvider::ClaudeCodeCli.default_exe(), "claude");
assert_eq!(CodingAgentProvider::OpenCodeCli.default_exe(), "opencode");
}
}
@@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter, Window};
use super::detect::{has_computer_use_mcp, McpServerStatus};
use super::guard::build_guard_settings_json;
use super::opencode::detect_opencode;
use super::{
claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent,
CodingAgentPermissionMode, CodingAgentRequest,
@@ -122,6 +123,45 @@ pub async fn coding_agent_detect(
})
}
/// OpenCode 检测结果(回前端,camelCase)。issue #579。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeDetectionWire {
/// 是否检测到可运行的 opencode。
pub installed: bool,
/// 版本号(如 "0.x.y")。
pub version: Option<String>,
/// 实际使用的可执行文件名/路径。
pub exe: String,
}
/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示
/// 用户是否需要先 `npm i -g opencode-ai` / 登录。
#[tauri::command]
pub async fn coding_agent_detect_opencode(
window: Window,
exe: Option<String>,
) -> Result<OpenCodeDetectionWire, String> {
ensure_main_window(&window)?;
let exe = exe
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "opencode".to_string());
// 拒绝路径遍历和相对路径(--version 探测仅允许裸名或绝对路径)。
if exe.contains("..") {
return Err("不允许的可执行文件路径: 包含 '..'".into());
}
if (exe.contains('/') || exe.contains('\\')) && !std::path::Path::new(&exe).is_absolute() {
return Err("不允许的相对路径,仅接受裸可执行文件名或绝对路径".into());
}
let version = detect_opencode(&exe).await;
Ok(OpenCodeDetectionWire {
installed: version.is_some(),
version,
exe,
})
}
/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
///
/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限;
@@ -150,6 +150,87 @@ pub fn build_guard_settings_json(mode: &str, extra_deny: &[String]) -> serde_jso
})
}
/// OpenCode `permission` 护栏的高风险 bash 子命令前缀(OpenCode glob 语法,如 `"rm *"`)。
///
/// OpenCode 的 `permission.bash` 是「glob → allow/ask/deny」映射(见官方 permissions 文档),
/// 与 Claude 的 `Bash(<prefix>:*)` 说明符是同类东西,但写法不同。这里把
/// [`default_deny_rules`] 里的 Bash 前缀翻译成 OpenCode glob。管道执行远程脚本
/// (`| sh`)、fork 炸弹等中段/shell 语法仍无法用前缀 glob 表达,由运行级
/// [`is_high_risk_command`] 兜底。
pub fn opencode_bash_deny_prefixes() -> Vec<&'static str> {
vec![
"rm -rf", "rm -fr", "sudo", "git push --force", "git push -f", "git reset --hard",
"git clean -fd", "git clean -f -d", "mkfs", "dd", "shutdown", "reboot", "chmod", "chown",
"crontab", "osascript", "launchctl", "kextload", "nvram",
]
}
/// 生成传给 OpenCode 的护栏配置(写入 `OPENCODE_CONFIG_CONTENT` inline JSON)。
///
/// 与 [`build_guard_settings_json`](Claude `--settings`)等价的 OpenCode 形态:
/// - `permission.bash`:默认 `allow`(放行可恢复轻动作),高风险前缀 `deny`;
/// `extra_allow_prefixes` 里的前缀(审批通过的)显式 `allow`,盖掉默认 deny。
/// - `permission.edit` / `permission.write`:补齐与 Claude [`default_deny_rules`] 对等的
/// 文件级 deny(.env / .git/** / macOS 持久化面)。若 OpenCode 版本不支持 per-file glob deny,
/// 这些键被静默忽略(无害);若支持则获得与 Claude 路径同级的文件保护。
/// - `permission.webfetch = "deny"`:去掉直抓任意 URL 面(与 Claude 路径不放 WebFetch 一致)。
/// - 其余工具(read/glob/grep/websearch)默认 `allow`,靠 `*: allow` 兜底,
/// 保持「放行 + 护栏」语义(高风险只在 bash/文件这两层拦)。
pub fn build_opencode_guard_config(extra_allow_prefixes: &[String]) -> serde_json::Value {
let mut bash: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
// 先放默认:未命中规则一律 allow(轻动作不打断无头执行)。
bash.insert("*".into(), serde_json::Value::String("allow".into()));
for prefix in opencode_bash_deny_prefixes() {
bash.insert(format!("{prefix} *"), serde_json::Value::String("deny".into()));
// 无参形式(如 `reboot`)也要拦:glob `reboot *` 不匹配光秃秃的 `reboot`。
bash.insert(prefix.to_string(), serde_json::Value::String("deny".into()));
}
// 审批放行:把通过的高风险前缀显式 allow,盖掉上面的 deny(后写覆盖)。
for prefix in extra_allow_prefixes {
bash.insert(format!("{prefix} *"), serde_json::Value::String("allow".into()));
bash.insert(prefix.clone(), serde_json::Value::String("allow".into()));
}
// 文件级 deny:与 Claude default_deny_rules() 对等 —— .env / .git/** / macOS 持久化面。
// 若 OpenCode 不支持 edit/write 的 per-file glob deny,该键被静默忽略(无害)。
let mut edit_rules: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
edit_rules.insert("*".into(), "allow".into());
for pat in [".env", ".git/**"] {
edit_rules.insert(pat.to_string(), "deny".into());
}
for pat in [
"~/Library/LaunchAgents/**",
"~/.zshrc",
"~/.zprofile",
"~/.bash_profile",
"~/.bashrc",
] {
edit_rules.insert(pat.to_string(), "deny".into());
}
let mut write_rules: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
write_rules.insert("*".into(), "allow".into());
for pat in [
"~/Library/LaunchAgents/**",
"~/.zshrc",
"~/.zprofile",
"~/.bash_profile",
"~/.bashrc",
] {
write_rules.insert(pat.to_string(), "deny".into());
}
serde_json::json!({
"permission": {
"*": "allow",
"bash": bash,
"edit": edit_rules,
"write": write_rules,
"webfetch": "deny",
}
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -193,6 +274,32 @@ mod tests {
assert!(deny.iter().any(|d| d == "Bash(npm publish:*)"));
}
#[test]
fn opencode_guard_denies_high_risk_bash_and_webfetch() {
let v = build_opencode_guard_config(&[]);
let perm = &v["permission"];
assert_eq!(perm["*"], "allow");
assert_eq!(perm["webfetch"], "deny");
let bash = &perm["bash"];
assert_eq!(bash["*"], "allow");
// 高风险前缀两种形态都拦(带参 glob + 无参)。
assert_eq!(bash["rm -rf *"], "deny");
assert_eq!(bash["sudo *"], "deny");
assert_eq!(bash["reboot"], "deny");
assert_eq!(bash["git push --force *"], "deny");
}
#[test]
fn opencode_guard_extra_allow_overrides_deny() {
let v = build_opencode_guard_config(&["git push --force".to_string()]);
let bash = &v["permission"]["bash"];
// 审批放行后,被批准的前缀变 allow(覆盖默认 deny)。
assert_eq!(bash["git push --force *"], "allow");
assert_eq!(bash["git push --force"], "allow");
// 未放行的仍然 deny。
assert_eq!(bash["rm -rf *"], "deny");
}
#[test]
fn default_deny_covers_perms_and_macos_persistence() {
let deny = default_deny_rules();
@@ -12,6 +12,7 @@ pub mod args;
pub mod commands;
pub mod detect;
pub mod guard;
pub mod opencode;
pub mod stream;
use std::path::Path;
@@ -23,8 +24,11 @@ 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 args::{
build_claude_args, CodingAgentPermissionMode, CodingAgentProvider, CodingAgentRequest,
};
pub use detect::McpServerStatus;
pub use opencode::run_opencode_agent;
pub use stream::{parse_stream_json_line, CodingAgentEvent};
/// 无头 Claude 的「自动化前置说明」。
@@ -68,7 +72,7 @@ pub enum CodingAgentError {
/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境,
/// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。
fn augment_env(cmd: &mut Command) {
pub(super) 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();
@@ -91,7 +95,7 @@ fn augment_env(cmd: &mut Command) {
cmd.env("PATH", path);
}
fn augmented_command(exe: &str) -> Command {
pub(super) fn augmented_command(exe: &str) -> Command {
let mut cmd = Command::new(exe);
augment_env(&mut cmd);
cmd
@@ -138,7 +142,7 @@ pub async fn claude_mcp_list(exe: &str) -> Vec<McpServerStatus> {
}
}
async fn wait_cancel(cancel: &Arc<AtomicBool>) {
pub(super) async fn wait_cancel(cancel: &Arc<AtomicBool>) {
loop {
if cancel.load(Ordering::Relaxed) {
return;
@@ -0,0 +1,368 @@
//! 无头 OpenCode(`opencode run`)适配器(issue #579)。
//!
//! 与 Claude Code 适配器([`super`] 顶层 + [`super::args`] / [`super::stream`])同形、复用
//! 同一套 [`CodingAgentRequest`] / [`CodingAgentEvent`] / [`CodingAgentError`],但对接的是
//! OpenCode CLI([opencode.ai](https://opencode.ai)):
//!
//! - 检测:`opencode --version` → 复用 [`super::detect::parse_claude_version`] 取 `x.y.z`。
//! - 运行:`opencode run --format json --model <provider/model> --dir <cwd>
//! [--dangerously-skip-permissions]`,**prompt 作为命令行参数**(OpenCode 从 argv 读,
//! 不像 claude 从 stdin),逐行解析 JSON 事件。
//! - 护栏:OpenCode 无 `--settings`,但支持 `permission` 配置;用
//! `OPENCODE_CONFIG_CONTENT` 环境变量注入 inline 护栏 JSON(precedence 高于项目
//! `opencode.json`),见 [`super::guard::build_opencode_guard_config`]。
//! - 输出:OpenCode 的 `text` 事件是「完成的文本块」(带 `part.time.end`)而非逐字 delta,
//! 且**没有**带 cost 的终局 `result` 事件——本模块在 EOF 处合成一条
//! [`CodingAgentEvent::Completed`],`cost_usd = None`(OpenCode CLI 不在 JSON 里给单次成本)。
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use super::stream::CodingAgentEvent;
use super::{augmented_command, wait_cancel, CodingAgentEventSink, CodingAgentRequest};
use super::{CodingAgentError, CodingAgentPermissionMode};
/// 探测 `opencode` 版本(`None` 表示未安装或无法运行)。复用 claude 的 `x.y.z` 解析。
pub async fn detect_opencode(exe: &str) -> Option<String> {
let out = augmented_command(exe).arg("--version").output().await.ok()?;
if !out.status.success() {
return None;
}
super::detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout))
}
/// OpenCode 权限模式到 CLI 的映射。OpenCode 只有一个二元的
/// `--dangerously-skip-permissions`(绕过所有非 deny 规则),没有 claude 的四档模式。
/// 我们靠注入的 `permission` 护栏配置(deny 高风险)做真正的拦截,因此除 `Plan` 外都需要
/// 加 skip 标志,否则无头下「ask」会卡死或被默认拒。`Plan` 不传 skip(OpenCode 自身只读)。
fn skip_permissions_flag(mode: CodingAgentPermissionMode) -> bool {
!matches!(mode, CodingAgentPermissionMode::Plan)
}
/// 构造 `opencode run` 的命令行参数(不含可执行文件本身,也不含 prompt——prompt 在运行器
/// 里作为**最后一个位置参数**追加在末尾的 `--` 之后)。
///
/// 安全(参数注入防护):参数列表以 `--`(end-of-options 标记)结尾,运行器把 prompt 接在
/// 其后。这样以 `-` / `--` 开头的 prompt(语音转写或被 prompt 注入诱导而成)都只会被当作
/// 位置参数,绝不会被 OpenCode 解析成 flag —— 杜绝经 prompt 混入 `--dangerously-skip-
/// permissions` 绕过护栏、`--dir` 改写工作目录等参数注入。model / cwd 作为各自 flag 的取值
/// 紧跟在 flag 之后,天然不会被当成独立 flag。
pub fn build_opencode_args(req: &CodingAgentRequest) -> Vec<String> {
let mut args: Vec<String> = vec!["run".into(), "--format".into(), "json".into()];
if let Some(model) = &req.model {
args.push("--model".into());
args.push(model.clone());
}
if let Some(cwd) = &req.cwd {
args.push("--dir".into());
args.push(cwd.to_string_lossy().into_owned());
}
if skip_permissions_flag(req.permission_mode) {
args.push("--dangerously-skip-permissions".into());
}
// end-of-options:其后由运行器追加的 prompt 一律按位置参数处理,不再解析成 flag。
args.push("--".into());
args
}
/// 解析一行 `opencode run --format json` 的 NDJSON。
///
/// OpenCode 事件形如 `{"type":"text|tool_use|step_start|step_finish|reasoning|error",
/// "sessionID":"…","part":{…}|"error":…}`。我们只关心:
/// - `text` → `part.text`(完整文本块,作为 Delta 抛出;运行器累计成最终结果)。
/// - `tool_use` → `part.tool`(工具名)。
/// - `error` → `error`(字符串或对象)。
///
/// 其余(step_start/step_finish/reasoning/未知)忽略。解析失败返回 `None`,不 panic。
pub fn parse_opencode_json_line(session_id: &str, line: &str) -> Option<CodingAgentEvent> {
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()? {
"text" => {
let text = v.get("part")?.get("text")?.as_str()?.to_string();
if text.is_empty() {
return None;
}
Some(CodingAgentEvent::Delta {
session_id: session_id.to_string(),
text,
})
}
"tool_use" => {
let name = v.get("part")?.get("tool")?.as_str()?.to_string();
Some(CodingAgentEvent::ToolUse {
session_id: session_id.to_string(),
name,
})
}
"error" => {
let message = match v.get("error") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => "agent 返回错误".to_string(),
};
Some(CodingAgentEvent::Error {
session_id: session_id.to_string(),
message,
})
}
_ => None,
}
}
/// 无头跑一次 OpenCode:prompt 作为 argv,逐行解析 NDJSON,把事件投到 `sink`。
/// 护栏 JSON 经 `OPENCODE_CONFIG_CONTENT` 注入。支持取消与超时(都会 kill 子进程)。
///
/// `guard_config_json`:[`super::guard::build_opencode_guard_config`] 的产物。`None` 表示
/// 不注入护栏(仅 Console 显式无护栏场景用;语音路径必须传 Some,调用方 fail-closed)。
pub async fn run_opencode_agent(
exe: &str,
req: CodingAgentRequest,
guard_config_json: Option<String>,
sink: CodingAgentEventSink,
cancel: Arc<AtomicBool>,
) -> Result<(), CodingAgentError> {
let args = build_opencode_args(&req);
let mut cmd = augmented_command(exe);
cmd.args(&args)
// prompt 作为最后的位置参数(OpenCode 从 argv 读取 message)。
.arg(&req.prompt)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = &req.cwd {
cmd.current_dir(cwd);
}
if let Some(cfg) = &guard_config_json {
cmd.env("OPENCODE_CONFIG_CONTENT", cfg);
}
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())
}
})?;
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));
// OpenCode 没有带最终文本/成本的 result 事件:累计所有 text 块,EOF 时合成 Completed。
let mut accumulated = String::new();
let mut got_error = 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() });
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_error = true;
outcome = Err(CodingAgentError::Timeout(req.timeout_secs));
break;
}
line = lines.next_line() => {
match line {
Ok(Some(l)) => {
if let Some(ev) = parse_opencode_json_line(&req.session_id, &l) {
match &ev {
CodingAgentEvent::Delta { text, .. } => accumulated.push_str(text),
CodingAgentEvent::Error { .. } => got_error = 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()))?;
// 正常 EOF 收尾(没取消/超时/IO 错):进程成功且没报错 → 合成 Completed。
if outcome.is_ok() {
if status.success() && !got_error {
let _ = sink.send(CodingAgentEvent::Completed {
session_id: req.session_id.clone(),
text: accumulated.trim().to_string(),
cost_usd: None,
duration_ms: None,
});
return Ok(());
}
if !status.success() && !got_error {
// 非 0 退出且没解析到 error 事件:补一条 Error(取 stderr 末行作摘要)。
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
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
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 run_args_are_json_format_and_prompt_not_in_argv() {
let req = CodingAgentRequest::new("s1", "hello world");
let args = build_opencode_args(&req);
assert_eq!(args.first().map(|s| s.as_str()), Some("run"));
assert_eq!(arg_value(&args, "--format"), Some("json"));
// prompt 不进 build_opencode_args(运行器单独追加)。
assert!(!args.iter().any(|a| a.contains("hello world")));
// 参数注入防护:末尾必须是 `--`,运行器把 prompt 接在其后。
assert_eq!(args.last().map(|s| s.as_str()), Some("--"));
}
#[test]
fn end_of_options_marker_is_last_even_with_flags() {
// 即便带 --model / --dir / --dangerously-skip-permissions,`--` 仍在最末,
// 保证以 `-` 开头的 prompt 不会被解析成 flag。
let mut req = CodingAgentRequest::new("s", "p");
req.model = Some("anthropic/claude-sonnet-4".into());
req.cwd = Some(PathBuf::from("/tmp/work"));
req.permission_mode = CodingAgentPermissionMode::AcceptEdits;
let args = build_opencode_args(&req);
assert_eq!(args.last().map(|s| s.as_str()), Some("--"));
// `--` 之前才是各 flag,`--dangerously-skip-permissions` 不在 `--` 之后。
let dd = args.iter().rposition(|a| a == "--").unwrap();
assert!(args[..dd].iter().any(|a| a == "--dangerously-skip-permissions"));
}
#[test]
fn plan_mode_omits_skip_permissions_other_modes_add_it() {
let mut req = CodingAgentRequest::new("s", "p");
req.permission_mode = CodingAgentPermissionMode::Plan;
assert!(!build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string()));
req.permission_mode = CodingAgentPermissionMode::AcceptEdits;
assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string()));
req.permission_mode = CodingAgentPermissionMode::BypassPermissions;
assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string()));
}
#[test]
fn model_and_dir_flags_emitted_when_set() {
let mut req = CodingAgentRequest::new("s", "p");
req.model = Some("anthropic/claude-sonnet-4".into());
req.cwd = Some(PathBuf::from("/tmp/work"));
let args = build_opencode_args(&req);
assert_eq!(arg_value(&args, "--model"), Some("anthropic/claude-sonnet-4"));
assert_eq!(arg_value(&args, "--dir"), Some("/tmp/work"));
}
#[test]
fn parses_text_part_as_delta() {
let line = r#"{"type":"text","sessionID":"x","part":{"type":"text","text":"你好"}}"#;
assert_eq!(
parse_opencode_json_line("s1", line),
Some(CodingAgentEvent::Delta {
session_id: "s1".into(),
text: "你好".into()
})
);
}
#[test]
fn parses_tool_use_part() {
let line = r#"{"type":"tool_use","sessionID":"x","part":{"type":"tool","tool":"bash"}}"#;
assert_eq!(
parse_opencode_json_line("s1", line),
Some(CodingAgentEvent::ToolUse {
session_id: "s1".into(),
name: "bash".into()
})
);
}
#[test]
fn parses_error_event() {
let line = r#"{"type":"error","sessionID":"x","error":"boom"}"#;
assert_eq!(
parse_opencode_json_line("s1", line),
Some(CodingAgentEvent::Error {
session_id: "s1".into(),
message: "boom".into()
})
);
}
#[test]
fn ignores_step_and_reasoning_and_garbage() {
assert_eq!(
parse_opencode_json_line("s1", r#"{"type":"step_start","part":{}}"#),
None
);
assert_eq!(
parse_opencode_json_line("s1", r#"{"type":"reasoning","part":{"text":"…"}}"#),
None
);
assert_eq!(parse_opencode_json_line("s1", "not json"), None);
assert_eq!(parse_opencode_json_line("s1", ""), None);
}
}
@@ -646,7 +646,7 @@ async fn run_voice_agent_transcript(
CapsuleState::Polishing,
0.0,
elapsed,
Some("Claude 处理中…".to_string()),
Some("Agent 处理中…".to_string()),
None,
);
@@ -742,7 +742,7 @@ async fn run_voice_agent_transcript(
LessComputerOutcome::Done { text, cost_usd } => {
let text = text.trim().to_string();
if text.is_empty() {
let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string();
let msg = "Agent 无结果(确认已登录且额度充足)".to_string();
emit_less_computer(
inner,
serde_json::json!({ "kind": "error", "message": msg }),
@@ -806,11 +806,22 @@ async fn run_less_computer_once(
extra_allow_patterns: &[String],
continue_session: bool,
) -> LessComputerOutcome {
// 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。
// 审批 UI 只回传命中的单个高风险子串,但同一风险有等价写法(如 --force / -f)。
// 按「风险等价组」整组放行:只放行被点那一个会让等价写法仍卡在 deny(deny 优先级高于
// allow)→ 命令仍被拦。见 guard::risk_equivalent_patterns。
let mut deny = crate::coding_agent::guard::default_deny_rules();
use crate::coding_agent::CodingAgentProvider;
let provider = CodingAgentProvider::from_pref(&inner.prefs.get().coding_agent_provider);
// 可配置可执行文件:用户在「高级 → Less Computer」填了路径就用它,留空/空白按后端取默认
// (claude / opencode)。trim 后为空视作未配置。
let configured_exe: Option<String> = inner
.prefs
.get()
.coding_agent_exe
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
// 审批放行的高风险子串按「风险等价组」整组放行(如 --force / -f):只放行被点那一个会让
// 等价写法仍被拦。Claude / OpenCode 共用这组前缀。见 guard::risk_equivalent_patterns。
let approved_patterns: Vec<String> = extra_allow_patterns
.iter()
.flat_map(|p| {
@@ -821,61 +832,15 @@ async fn run_less_computer_once(
group.into_iter().map(|s| s.to_string()).collect()
}
})
// 不可安全批准的模式(提权/毁盘/系统级如 "sudo "、"dd if=" 等,deny_rule_for_pattern
// 返回 None)在审批阶段保持拦截,不注入 allow 列表也不生成 OpenCode allow glob。
.filter(|p| crate::coding_agent::guard::deny_rule_for_pattern(p).is_some())
.collect();
// 只放行「可批准」的命令:deny_rule_for_pattern 返回该 pattern 在 default_deny_rules 里的
// 精确 deny 规则;提权/毁盘/系统级等不可安全表达的命令返回 None → 即使被批准也保持拦截
// (fail-closed),且不向 allow 注入畸形规则。允许的 allow 规则与被移除的 deny 严格一致。
let allow_rules: Vec<String> = approved_patterns
.iter()
.filter_map(|p| crate::coding_agent::guard::deny_rule_for_pattern(p))
.map(|rule| rule.to_string())
.collect();
if !allow_rules.is_empty() {
deny.retain(|d| !allow_rules.iter().any(|a| a == d));
}
let settings_json = serde_json::json!({
"permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny }
});
let settings_path = std::env::temp_dir().join(format!(
"openless-less-computer-guard-{}.json",
uuid::Uuid::new_v4()
));
// fail-closed:序列化或写入失败时立即中止,绝不在「无护栏」下把无效路径交给
// `claude -p --settings`(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。
let settings_bytes = match serde_json::to_vec_pretty(&settings_json) {
Ok(b) => b,
Err(e) => {
log::warn!("[less-computer] 序列化护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
};
if let Err(e) = std::fs::write(&settings_path, settings_bytes) {
log::warn!("[less-computer] 写护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string());
req.cwd = cwd.map(|p| p.to_path_buf());
req.model = model.map(|m| m.to_string());
req.permission_mode = mode;
// 写护栏成功后才设置:写失败已在上面 fail-closed 返回,不会带无效路径裸跑。
req.settings_json_path = Some(settings_path.clone());
// 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面(诱导拉取内网/元数据端点)。
// 保留 WebSearch(走搜索引擎,不直接抓任意 URL)。
req.allowed_tools = vec![
"Bash".into(),
"Read".into(),
"Edit".into(),
"Write".into(),
"Glob".into(),
"Grep".into(),
"WebSearch".into(),
];
req.allowed_tools.extend(allow_rules);
// 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到
// 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。
req.max_budget_usd = Some(2.0);
@@ -887,9 +852,97 @@ async fn run_less_computer_once(
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_runner = Arc::clone(&cancel);
let run = async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await
});
// 护栏 + 运行器按 provider 分派。两条路径都 fail-closed:护栏配置生成失败一律中止,
// 绝不在无护栏下裸跑。`settings_path` 仅 Claude 路径用临时文件(OpenCode 走 env 注入,
// 无临时文件需清理)。
let settings_path: Option<std::path::PathBuf>;
let run = match provider {
CodingAgentProvider::ClaudeCodeCli => {
// 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。
let mut deny = crate::coding_agent::guard::default_deny_rules();
// 只放行「可批准」的命令:deny_rule_for_pattern 返回该 pattern 在 default_deny_rules
// 里的精确 deny 规则;提权/毁盘/系统级等不可安全表达的命令返回 None → 即使被批准也
// 保持拦截(fail-closed),且不向 allow 注入畸形规则。
let allow_rules: Vec<String> = approved_patterns
.iter()
.filter_map(|p| crate::coding_agent::guard::deny_rule_for_pattern(p))
.map(|rule| rule.to_string())
.collect();
if !allow_rules.is_empty() {
deny.retain(|d| !allow_rules.iter().any(|a| a == d));
}
let settings_json = serde_json::json!({
"permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny }
});
let path = std::env::temp_dir().join(format!(
"openless-less-computer-guard-{}.json",
uuid::Uuid::new_v4()
));
// fail-closed:序列化或写入失败时立即中止,绝不把无效路径交给 `claude -p --settings`
//(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。
let settings_bytes = match serde_json::to_vec_pretty(&settings_json) {
Ok(b) => b,
Err(e) => {
log::warn!("[less-computer] 序列化护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
};
if let Err(e) = std::fs::write(&path, settings_bytes) {
log::warn!("[less-computer] 写护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
settings_path = Some(path.clone());
req.settings_json_path = Some(path);
// 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面。保留 WebSearch(走搜索引擎)。
req.allowed_tools = vec![
"Bash".into(),
"Read".into(),
"Edit".into(),
"Write".into(),
"Glob".into(),
"Grep".into(),
"WebSearch".into(),
];
req.allowed_tools.extend(allow_rules);
let exe = configured_exe.unwrap_or_else(|| "claude".to_string());
async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent(&exe, req, tx, cancel_for_runner).await
})
}
CodingAgentProvider::OpenCodeCli => {
// OpenCode 无 `--settings`,护栏走 `permission` 配置经 OPENCODE_CONFIG_CONTENT 注入。
// build_opencode_guard_config 默认 bash deny 高风险前缀、webfetch deny,审批放行的
// 前缀显式 allow。fail-closed:序列化失败立即中止,绝不无护栏裸跑。
let guard =
crate::coding_agent::guard::build_opencode_guard_config(&approved_patterns);
let guard_str = match serde_json::to_string(&guard) {
Ok(s) => s,
Err(e) => {
log::warn!("[less-computer] 序列化 OpenCode 护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
};
settings_path = None;
let exe = configured_exe.unwrap_or_else(|| "opencode".to_string());
async_runtime::spawn(async move {
crate::coding_agent::run_opencode_agent(
&exe,
req,
Some(guard_str),
tx,
cancel_for_runner,
)
.await
})
}
};
let cancel_for_watcher = Arc::clone(&cancel);
let inner_for_cancel = Arc::clone(inner);
let cancel_watcher = async_runtime::spawn(async move {
@@ -934,7 +987,10 @@ async fn run_less_computer_once(
let run_result = run.await;
cancel.store(true, Ordering::Relaxed);
let _ = cancel_watcher.await;
let _ = std::fs::remove_file(&settings_path);
// 仅 Claude 路径有临时护栏文件需清理;OpenCode 走 env 注入无文件。
if let Some(path) = &settings_path {
let _ = std::fs::remove_file(path);
}
if cancelled
|| matches!(
@@ -957,7 +1013,7 @@ async fn run_less_computer_once(
Ok(Err(e)) => Some(e.to_string()),
_ => None,
})
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
.unwrap_or_else(|| "Agent 无结果(确认已登录且额度充足)".to_string());
LessComputerOutcome::Failed { message }
}
}
+1
View File
@@ -187,6 +187,7 @@ macro_rules! app_invoke_handler_desktop {
commands::stop_dictation,
commands::cancel_dictation,
coding_agent::commands::coding_agent_detect,
coding_agent::commands::coding_agent_detect_opencode,
coding_agent::commands::coding_agent_run_test,
coding_agent::commands::coding_agent_cancel_test,
coding_agent::commands::coding_agent_command_risk,
+9
View File
@@ -644,6 +644,10 @@ pub struct UserPreferences {
/// Agent 工作目录(`None` = 临时目录)。
#[serde(default)]
pub coding_agent_workdir: Option<String>,
/// Agent 可执行文件路径/命令(`None` 或空白 = 按后端取默认 `claude` / `opencode`)。
/// 供用户在「高级 → Less Computer」填自定义路径(例如未加入 PATH 的 opencode 二进制)。
#[serde(default)]
pub coding_agent_exe: Option<String>,
/// Less Computer 语音触发键。macOS 生效;支持单修饰键(左/右 Control、左/右 Option、Fn)
/// 和普通组合键。`None` = 停用。
#[serde(default = "default_coding_agent_voice_hotkey")]
@@ -904,6 +908,8 @@ struct UserPreferencesWire {
coding_agent_permission_mode: String,
#[serde(default)]
coding_agent_workdir: Option<String>,
#[serde(default)]
coding_agent_exe: Option<String>,
#[serde(default = "default_coding_agent_voice_hotkey")]
coding_agent_voice_hotkey: Option<ShortcutBinding>,
#[serde(default = "default_coding_agent_panel_hotkey")]
@@ -1020,6 +1026,7 @@ impl Default for UserPreferencesWire {
coding_agent_model: prefs.coding_agent_model,
coding_agent_permission_mode: prefs.coding_agent_permission_mode,
coding_agent_workdir: prefs.coding_agent_workdir,
coding_agent_exe: prefs.coding_agent_exe,
coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey,
coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey,
coding_agent_quick_hotkey: prefs.coding_agent_quick_hotkey,
@@ -1115,6 +1122,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
coding_agent_model: wire.coding_agent_model,
coding_agent_permission_mode: wire.coding_agent_permission_mode,
coding_agent_workdir: wire.coding_agent_workdir,
coding_agent_exe: wire.coding_agent_exe,
coding_agent_voice_hotkey: wire.coding_agent_voice_hotkey,
coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey,
coding_agent_quick_hotkey: wire.coding_agent_quick_hotkey,
@@ -1862,6 +1870,7 @@ impl Default for UserPreferences {
coding_agent_model: None,
coding_agent_permission_mode: default_coding_agent_permission_mode(),
coding_agent_workdir: None,
coding_agent_exe: None,
coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(),
coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(),
coding_agent_quick_hotkey: None,
+3 -1
View File
@@ -608,7 +608,8 @@ export const en: typeof zhCN = {
voiceHotkey: 'Hold-to-talk key',
voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys.',
provider: 'Agent backend',
providerOpenCodeSoon: 'OpenCode (coming soon)',
opencodeReady: 'OpenCode v{{version}} detected.',
opencodeMissing: 'opencode command not found. Install it (npm i -g opencode-ai) and sign in with opencode auth login before use.',
panelHotkey: 'Panel hotkey (voice agent)',
panelHotkeyDesc: 'Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.',
quickHotkey: 'Quick-take hotkey',
@@ -617,6 +618,7 @@ export const en: typeof zhCN = {
modelPlaceholder: 'Default: sonnet',
modelDefault: 'Default (auto sonnet)',
modelHint: 'Haiku = fastest · Sonnet = balanced · Opus = strongest',
exe: 'Executable path',
},
debug: {
title: 'Debug tools',
+3 -1
View File
@@ -610,7 +610,8 @@ export const ja: typeof zhCN = {
voiceHotkey: '押しながら話すキー',
voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。',
provider: 'Agent バックエンド',
providerOpenCodeSoon: 'OpenCode(近日対応)',
opencodeReady: 'OpenCode v{{version}} を検出しました。',
opencodeMissing: 'opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。',
panelHotkey: 'パネルキー(音声 Agent)',
panelHotkeyDesc: '録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。',
quickHotkey: 'クイック取得キー',
@@ -619,6 +620,7 @@ export const ja: typeof zhCN = {
modelPlaceholder: 'デフォルト: sonnet',
modelDefault: 'デフォルト(自動 sonnet)',
modelHint: 'Haiku = 最速 · Sonnet = バランス · Opus = 最強',
exe: '実行ファイルのパス',
},
debug: {
title: 'デバッグツール',
+3 -1
View File
@@ -610,7 +610,8 @@ export const ko: typeof zhCN = {
voiceHotkey: '누르고 말하기 키',
voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원.',
provider: 'Agent 백엔드',
providerOpenCodeSoon: 'OpenCode(곧 지원)',
opencodeReady: 'OpenCode v{{version}} 감지됨.',
opencodeMissing: 'opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.',
panelHotkey: '패널 키(음성 Agent)',
panelHotkeyDesc: '녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.',
quickHotkey: '빠른 가져오기 키',
@@ -619,6 +620,7 @@ export const ko: typeof zhCN = {
modelPlaceholder: '기본: sonnet',
modelDefault: '기본(자동 sonnet)',
modelHint: 'Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강',
exe: '실행 파일 경로',
},
debug: {
title: '디버그 도구',
+3 -1
View File
@@ -606,7 +606,8 @@ export const zhCN = {
voiceHotkey: '按住说话键',
voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。',
provider: 'Agent 后端',
providerOpenCodeSoon: 'OpenCode(即将支持)',
opencodeReady: '已检测到 OpenCode v{{version}}。',
opencodeMissing: '未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。',
panelHotkey: '面板键(语音 Agent)',
panelHotkeyDesc: '录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。',
quickHotkey: '快取用键',
@@ -615,6 +616,7 @@ export const zhCN = {
modelPlaceholder: '默认 sonnet',
modelDefault: '默认(自动 sonnet)',
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最强',
exe: '可执行文件路径',
},
debug: {
title: '调试工具',
+3 -1
View File
@@ -608,7 +608,8 @@ export const zhTW: typeof zhCN = {
voiceHotkey: '按住說話鍵',
voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。',
provider: 'Agent 後端',
providerOpenCodeSoon: 'OpenCode(即將支援)',
opencodeReady: '已偵測到 OpenCode v{{version}}。',
opencodeMissing: '未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。',
panelHotkey: '面板鍵(語音 Agent)',
panelHotkeyDesc: '錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。',
quickHotkey: '快取用鍵',
@@ -617,6 +618,7 @@ export const zhTW: typeof zhCN = {
modelPlaceholder: '預設 sonnet',
modelDefault: '預設(自動 sonnet)',
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最強',
exe: '可執行檔路徑',
},
debug: {
title: '除錯工具',
@@ -18,6 +18,26 @@ export interface ClaudeDetection {
hasComputerUse: boolean
}
/** OpenCode CLI 检测结果(issue #579)。 */
export interface OpenCodeDetection {
installed: boolean
version: string | null
exe: string
}
/** 检测 `opencode` 是否安装(语音 Agent 选 OpenCode 后端时设置页据此提示)。 */
export function codingAgentDetectOpencode(exe?: string): Promise<OpenCodeDetection> {
return invokeOrMock(
"coding_agent_detect_opencode",
{ exe },
() => ({
installed: false,
version: null,
exe: exe || "opencode",
}),
)
}
/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */
export type CodingAgentEvent =
| { kind: "started"; session_id: string }
+2
View File
@@ -161,10 +161,12 @@ export type {
export type {
McpServerStatus,
ClaudeDetection,
OpenCodeDetection,
CodingAgentRunTestArgs,
} from "./coding-agent"
export {
codingAgentDetect,
codingAgentDetectOpencode,
codingAgentRunTest,
codingAgentCancelTest,
codingAgentCommandRisk,
@@ -67,6 +67,7 @@ export let mockSettings: UserPreferences = {
codingAgentModel: null,
codingAgentPermissionMode: "acceptEdits",
codingAgentWorkdir: null,
codingAgentExe: null,
codingAgentVoiceHotkey: { primary: "LeftControl", modifiers: [] },
codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] },
codingAgentQuickHotkey: null,
@@ -54,6 +54,7 @@ const previousPrefs: UserPreferences = {
codingAgentModel: null,
codingAgentPermissionMode: 'acceptEdits',
codingAgentWorkdir: null,
codingAgentExe: null,
codingAgentVoiceHotkey: { primary: 'LeftControl', modifiers: [] },
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
codingAgentQuickHotkey: null,
+2
View File
@@ -298,6 +298,8 @@ export interface UserPreferences {
codingAgentPermissionMode: CodingAgentPermissionMode;
/** Agent 工作目录,null = 临时目录。 */
codingAgentWorkdir: string | null;
/** Agent 可执行文件路径/命令,null 或空 = 按后端取默认(claude / opencode)。 */
codingAgentExe: string | null;
/** Less Computer 按住说话快捷键。null = 停用;目前仅 macOS 显示/生效。 */
codingAgentVoiceHotkey: ShortcutBinding | null;
/** 热键 1:语音 Agent 面板键。null = 停用。 */
@@ -2,8 +2,10 @@
// 「按住说话键」在 通用 → 快捷键 里配置(见 ShortcutsSection),这里不再重复。
// 配置经 UserPreferences 持久化;启用后 coordinator 才注册热键。
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { detectOS } from '../../components/WindowChrome'
import { codingAgentDetectOpencode, type OpenCodeDetection } from '../../lib/ipc'
import type { CodingAgentPermissionMode, CodingAgentProviderId } from '../../lib/types'
import { useHotkeySettings } from '../../state/HotkeySettingsContext'
import { Card } from '../_atoms'
@@ -21,6 +23,24 @@ export function CodingAgentSection() {
const { prefs, updatePrefs: savePrefs } = useHotkeySettings()
const os = detectOS()
// OpenCode 安装检测:仅当启用 + 选了 OpenCode 后端时探测一次,用于提示是否需先安装。
const [opencode, setOpencode] = useState<OpenCodeDetection | null>(null)
const useOpencode = prefs?.codingAgentEnabled && prefs?.codingAgentProvider === 'opencode-cli'
useEffect(() => {
if (!useOpencode) {
setOpencode(null)
return
}
let alive = true
// 把配置的可执行路径传给检测,用户改了路径后能重新探测对应二进制。
void codingAgentDetectOpencode(prefs?.codingAgentExe ?? undefined).then(d => {
if (alive) setOpencode(d)
})
return () => {
alive = false
}
}, [useOpencode, prefs?.codingAgentExe])
if (os === 'win') return null
if (!prefs) {
@@ -60,10 +80,26 @@ export function CodingAgentSection() {
style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }}
>
<option value="claude-code-cli">Claude Code</option>
<option value="opencode-cli">{t('settings.codingAgent.providerOpenCodeSoon')}</option>
<option value="opencode-cli">OpenCode</option>
</select>
</SettingRow>
{/* OpenCode 后端:提示安装/登录状态。issue #579。 */}
{useOpencode && opencode && (
<div
style={{
fontSize: 12,
lineHeight: 1.6,
color: opencode.installed ? 'var(--ol-ink-3)' : 'var(--ol-warn, #b8860b)',
margin: '-4px 0 8px',
}}
>
{opencode.installed
? t('settings.codingAgent.opencodeReady', { version: opencode.version ?? '?' })
: t('settings.codingAgent.opencodeMissing')}
</div>
)}
<SettingRow label={t('settings.codingConsole.permissionMode')}>
<select
value={prefs.codingAgentPermissionMode}
@@ -112,6 +148,20 @@ export function CodingAgentSection() {
style={inputStyle}
/>
</SettingRow>
<SettingRow label={t('settings.codingAgent.exe')}>
<input
type="text"
value={prefs.codingAgentExe ?? ''}
placeholder={prefs.codingAgentProvider === 'opencode-cli' ? 'opencode' : 'claude'}
spellCheck={false}
onChange={e => {
const v = e.target.value.trim()
void savePrefs({ ...prefs, codingAgentExe: v === '' ? null : v })
}}
style={inputStyle}
/>
</SettingRow>
</>
)}
</Card>