diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 2f7f92c04..9b5d8b0f6 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -889,6 +889,18 @@ fn background_task_summary(payload: &Value) -> Option { Some(AiBackgroundTasks { active, total }) } +/// 数出这批后台活儿里有多少还在跑。 +/// +/// 旧实现只认 `type == "subagent"`,其余类型一律跳过。而 Claude Code 给后台 bash +/// 的类型名是 `local_bash`(同族还有 `monitor` / `workflow` / `mcp_task` / +/// `in_process_teammate` …),于是「跑着一条后台命令、回合先结束」这个最常见、也 +/// 最容易被误报成「任务完成」的形状,永远数出 `active = 0`:`TurnDone` 的守卫不 +/// 触发,命令还在跑就先弹了完成通知。 +/// +/// `background_tasks` 本身就是「在飞」的集合——字段说明写着 *In-flight background +/// work … Empty array when nothing is in flight*,它的存在就是为了让 hook 区分 +/// 「真的收工」和「在等后台活儿把自己叫醒」。所以这里不再按类型过滤,只看每笔的 +/// 状态。 fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) { match value { Value::Array(values) => { @@ -897,21 +909,14 @@ fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) { } }, Value::Object(task) => { - if let Some(kind) = task.get("type").and_then(Value::as_str) { - if !kind.eq_ignore_ascii_case("subagent") { - return; - } + if is_task_entry(task) { *total = total.saturating_add(1); - if task.get("status").and_then(Value::as_str).is_some_and(|status| { - matches!( - status.to_ascii_lowercase().as_str(), - "running" | "processing" | "in_progress" | "active" - ) - }) { + if task_is_in_flight(task) { *active = active.saturating_add(1); } return; } + // 既没有类型也没有状态的中间层对象(旧 wire 形状的包装)继续往下找。 for value in task.values() { count_background_tasks(value, active, total); } @@ -920,6 +925,76 @@ fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) { } } +/// 任务条目的终态词表。 +/// +/// 取值不是猜的:本机 claude 2.1.270 二进制里出现过的 `status:"…"` 字面量是 +/// `failed` / `success` / `pending` / `completed` / `running` / `killed` / `idle` / +/// `stopped` / `cancelled` / `aborted` / `exited`。这里取其中的终态,再补上同族词 +/// (`done` / `finished` / `error` / `canceled` …)。 +/// +/// 漏掉终态会让 pane 一直等待后台任务;误把运行态加入这张表则会提前通知完成。 +/// 缺失或未知状态按仍在运行处理,避免 provider 扩展类型时恢复提前完成的问题。 +const TERMINAL_TASK_STATUSES: &[&str] = &[ + "completed", + "complete", + "done", + "success", + "succeeded", + "finished", + "exited", + "failed", + "failure", + "error", + "timeout", + "timed_out", + "expired", + "terminated", + "cancelled", + "canceled", + "stopped", + "killed", + "aborted", + // 任务自己说 idle,就是没在干活——它仍留在集合里只是注册表还没清扫。 + "idle", +]; + +/// 这一笔是任务条目,还是需要继续往下找的中间层包装。 +/// +/// 只认**字符串**字段:`{"type": null}`、`{"type": {"name": …}}` 既不是任务也没有 +/// 状态,放行给下一层递归(旧实现按 `as_str()` 判断,本次改动一度退化成 +/// `is_some()`,会把一个 null 凭空当成一笔在飞任务)。 +fn is_task_entry(task: &serde_json::Map) -> bool { + task.get("type").and_then(Value::as_str).is_some() + || task.get("status").and_then(Value::as_str).is_some() +} + +/// 这一笔后台活儿是否还在跑。 +/// +/// 读不到状态就按还在跑处理:这个集合本身就是「在飞」的任务。 +fn task_is_in_flight(task: &serde_json::Map) -> bool { + if is_idle_teammate(task) { + return false; + } + match task.get("status").and_then(Value::as_str) { + Some(status) => !TERMINAL_TASK_STATUSES.contains(&status.to_ascii_lowercase().as_str()), + None => true, + } +} + +/// 闲置的 `in_process_teammate`:`status` 会一直挂着 `running` +/// (anthropics/claude-code#85955),只有它自己的 `isIdle` 说得准——这也正是 +/// Claude Code 内部的判据(`type === "in_process_teammate" && status === +/// "running" && !isIdle`)。 +/// +/// **只对 teammate 生效**:别的类型没有 `isIdle` 的约定,拿它压掉一笔在跑的 +/// `local_bash` 就是谎报完成,正好是这次要修的那个毛病。 +fn is_idle_teammate(task: &serde_json::Map) -> bool { + task.get("type") + .and_then(Value::as_str) + .is_some_and(|kind| kind.eq_ignore_ascii_case("in_process_teammate")) + && task.get("isIdle").and_then(Value::as_bool) == Some(true) +} + fn sanitized_raw_context(payload: &Value) -> Option { let sanitized = sanitize_json(payload, 0); let raw = serde_json::to_string(&sanitized).ok()?; @@ -1199,18 +1274,94 @@ mod remote_tests { assert!(!summary.contains("selected source")); } + /// `background_tasks` 里的**每一笔**在飞的活儿都算数,不只是 subagent。 + /// + /// 2026-09-14:旧实现只认 `type == "subagent"`,而 Claude Code 给后台 bash 的 + /// 类型名是 `local_bash`——「跑着后台命令、回合先结束」于是永远数出 active=0, + /// `TurnDone` 的守卫不触发,用户在命令还在跑时就收到「回合完成」。 + /// 类型名取自本机 claude 2.1.270 二进制里的集合:`local_bash` / `subagent` / + /// `monitor` / `workflow` / `mcp_task` / `in_process_teammate` / `local_agent` / + /// `remote_agent` / `dream` / `auto_mode_scan` / `cloud_session`。 #[test] - fn claude_stop_reports_running_background_subagents() { + fn claude_stop_counts_every_in_flight_background_task() { let raw = br#"nebula-hook/1 source=claude pane=3 -{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"subagent","status":"running"},{"type":"subagent","status":"completed"},{"type":"other","status":"running"}]}"#; +{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"local_bash","status":"running"},{"type":"subagent","status":"completed"},{"type":"monitor","status":"running"},{"type":"mcp_task","status":"cancelled"}]}"#; let event = parse_remote_envelope(raw, Some(3)).unwrap(); assert_eq!(event.kind, AiHookKind::TurnDone); - assert_eq!(event.active_background_tasks(), 1); - assert_eq!(event.background_tasks.unwrap().total, 2); + assert_eq!(event.active_background_tasks(), 2, "local_bash 与 monitor 都还在跑"); + assert_eq!(event.background_tasks.unwrap().total, 4); assert!(capabilities_for("claude").background_tasks); assert!(!capabilities_for("pi").attention_context); } + /// 空数组 = 真的收工:守卫必须放行,否则完成通知永远不弹。 + #[test] + fn empty_background_tasks_leave_the_turn_done() { + let raw = br#"nebula-hook/1 source=claude pane=3 +{"session_id":"s","hook_event_name":"Stop","background_tasks":[]}"#; + let event = parse_remote_envelope(raw, Some(3)).unwrap(); + assert_eq!(event.active_background_tasks(), 0); + assert_eq!(event.background_tasks.unwrap().total, 0); + } + + #[test] + fn pending_and_unknown_background_work_prevent_premature_completion() { + let payload = serde_json::json!({ + "hook_event_name": "Stop", + "background_tasks": { "entries": [ + { "type": "local_bash", "status": "pending" }, + { "type": "future_task", "status": "waiting_for_resource" }, + { "type": "monitor" }, + { "type": "subagent", "status": "COMPLETED" } + ] } + }); + let raw = format!("nebula-hook/1 source=claude pane=3\n{payload}"); + let event = parse_remote_envelope(raw.as_bytes(), Some(3)).unwrap(); + assert_eq!(event.kind, AiHookKind::TurnDone); + assert_eq!(event.active_background_tasks(), 3); + assert_eq!(event.background_tasks.unwrap().total, 4); + } + + /// 闲置的 `in_process_teammate` 会一直挂 `status: "running"` + /// (anthropics/claude-code#85955),只有它自己的 `isIdle` 能说明它没在干活。 + /// 少了这道判据,pane 会永远停在「还在跑」,完成通知再也弹不出来。 + #[test] + fn idle_in_process_teammate_does_not_hold_the_turn_open() { + let raw = br#"nebula-hook/1 source=claude pane=3 +{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"in_process_teammate","status":"running","isIdle":true},{"type":"in_process_teammate","status":"running","isIdle":false}]}"#; + let event = parse_remote_envelope(raw, Some(3)).unwrap(); + assert_eq!(event.active_background_tasks(), 1, "只有没闲置的那个算在跑"); + } + + /// 终态词表必须覆盖真实取值:`success` / `exited` 是从本机 claude 2.1.270 里 + /// 读到的字面量,漏掉它们会让 pane 永远停在「工作中」——完成通知不再弹, + /// `agent.delegate` 的完成回调也不会触发,比早弹一条更难发现。 + #[test] + fn finished_background_tasks_do_not_hold_the_turn_open() { + for status in ["completed", "success", "exited", "failed", "killed", "cancelled", "idle"] { + let payload = serde_json::json!({ + "session_id": "s", + "hook_event_name": "Stop", + "background_tasks": [{ "type": "local_bash", "status": status }], + }); + let raw = format!("nebula-hook/1 source=claude pane=3\n{payload}"); + let event = parse_remote_envelope(raw.as_bytes(), Some(3)).unwrap(); + assert_eq!(event.active_background_tasks(), 0, "status={status} 是终态"); + assert_eq!(event.background_tasks.unwrap().total, 1, "它仍然算一笔任务"); + } + } + + /// 两个形状陷阱:`{"type": null}` 不是任务(不能凭空造出一笔在飞的活儿), + /// `isIdle` 只对 teammate 有约定(别拿它压掉一笔在跑的 `local_bash`)。 + #[test] + fn malformed_task_shapes_do_not_manufacture_in_flight_work() { + let raw = br#"nebula-hook/1 source=claude pane=3 +{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":null},{"type":"local_bash","status":"running","isIdle":true}]}"#; + let event = parse_remote_envelope(raw, Some(3)).unwrap(); + assert_eq!(event.active_background_tasks(), 1, "isIdle 不适用于 local_bash"); + assert_eq!(event.background_tasks.unwrap().total, 1, "null 型别不算任务"); + } + #[test] fn distinct_original_answers_are_not_swallowed_by_preview_deduplication() { for provider in ["claude", "codex"] { diff --git a/nebula_app/src/daemon.rs b/nebula_app/src/daemon.rs index acdfb339c..154ce14c5 100644 --- a/nebula_app/src/daemon.rs +++ b/nebula_app/src/daemon.rs @@ -8,8 +8,6 @@ use std::fs; use std::io; #[cfg(not(windows))] use std::os::unix::ffi::OsStringExt; -#[cfg(windows)] -use std::os::windows::process::CommandExt; use std::process::{Command, Stdio}; #[cfg(target_os = "openbsd")] use std::ptr; @@ -26,7 +24,7 @@ use { #[cfg(not(windows))] use libc::pid_t; #[cfg(windows)] -use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW}; +use windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; #[cfg(target_os = "macos")] use crate::macos; @@ -38,16 +36,12 @@ where I: IntoIterator + Copy, S: AsRef, { - // Setting all the I/O handles to null and setting the - // CREATE_NEW_PROCESS_GROUP and CREATE_NO_WINDOW has the effect - // that console applications will run without opening a new - // console window. - Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) + // 三个流都接到 null、再带上 CREATE_NEW_PROCESS_GROUP 与 CREATE_NO_WINDOW, + // 控制台子进程就不会另开窗口(`CREATE_NO_WINDOW` 由 `platform::process` + // 负责,这里只补它自己需要的进程组标志)。 + let mut command = Command::new(program); + command.args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + crate::platform::process::hidden_command_with(&mut command, CREATE_NEW_PROCESS_GROUP) .spawn() .map(|_| ()) } diff --git a/nebula_app/src/display/command_completion.rs b/nebula_app/src/display/command_completion.rs index 0d67b7c7c..f5b1cc434 100644 --- a/nebula_app/src/display/command_completion.rs +++ b/nebula_app/src/display/command_completion.rs @@ -141,18 +141,15 @@ fn nebula_collect_commands() -> Vec { /// commands as the available completion source. #[cfg(windows)] fn nebula_powershell_commands() -> Vec { - use std::os::windows::process::CommandExt; - - let output = std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ - | Select-Object -ExpandProperty Name", - ]) - .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) - .output(); + let mut command = std::process::Command::new("powershell"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ + | Select-Object -ExpandProperty Name", + ]); + let output = crate::platform::process::hidden_command(&mut command).output(); match output { Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) diff --git a/nebula_app/src/display/side_panel/enumerate.rs b/nebula_app/src/display/side_panel/enumerate.rs index 5d8242475..096f7614c 100644 --- a/nebula_app/src/display/side_panel/enumerate.rs +++ b/nebula_app/src/display/side_panel/enumerate.rs @@ -118,11 +118,7 @@ pub(crate) fn run_wsl_find_lenient( ) -> Option<(Vec, bool)> { let mut command = std::process::Command::new("wsl.exe"); command.args(["-d", distro, "--", "find"]).args(args); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } + crate::platform::process::hidden_command(&mut command); let output = match command_output_with_timeout(command, Some(WSL_COMMAND_TIMEOUT)) { Ok(output) => output, Err(error) => { diff --git a/nebula_app/src/display/side_panel/gitignore.rs b/nebula_app/src/display/side_panel/gitignore.rs index 6d9db4f8a..e8efdd3b7 100644 --- a/nebula_app/src/display/side_panel/gitignore.rs +++ b/nebula_app/src/display/side_panel/gitignore.rs @@ -133,11 +133,7 @@ fn matching_rule(root: &Path, path: &Path) -> Result, String> .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); - } + crate::platform::process::hidden_command(&mut command); let mut child = command.spawn().map_err(|error| error.to_string())?; let mut stdin = child.stdin.take().ok_or("git stdin unavailable")?; let relative = relative.to_string_lossy(); diff --git a/nebula_app/src/display/side_panel/gitignore_tests.rs b/nebula_app/src/display/side_panel/gitignore_tests.rs index ced9c5366..413a73b9e 100644 --- a/nebula_app/src/display/side_panel/gitignore_tests.rs +++ b/nebula_app/src/display/side_panel/gitignore_tests.rs @@ -2,14 +2,11 @@ use super::*; fn repository() -> tempfile::TempDir { let directory = tempfile::tempdir().unwrap(); - assert!( - std::process::Command::new("git") - .args(["init", "-q"]) - .arg(directory.path()) - .status() - .unwrap() - .success() - ); + // 测试二进制没有控制台:不压掉这个 flag,每次 `git init` 都会在用户屏幕上 + // 弹一个终端窗口(见 `platform::process`)。 + let mut command = std::process::Command::new("git"); + command.args(["init", "-q"]).arg(directory.path()); + assert!(crate::platform::process::hidden_command(&mut command).status().unwrap().success()); directory } diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index a4d581416..d0a19943a 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -1344,11 +1344,7 @@ impl SidePanel { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); - } + crate::platform::process::hidden_command(&mut command); let Ok(mut child) = command.spawn() else { return }; let Some(mut stdin) = child.stdin.take() else { return }; for path in candidates { diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs index bb57bb3e7..973099190 100644 --- a/nebula_app/src/display/side_panel/vcs.rs +++ b/nebula_app/src/display/side_panel/vcs.rs @@ -285,11 +285,7 @@ impl SidePanel { std::thread::Builder::new().name("nebula-vcs-op".into()).spawn(move || { let mut cmd = std::process::Command::new(&program); cmd.args(&args).current_dir(&root); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } + crate::platform::process::hidden_command(&mut cmd); let msg = match cmd.output() { Ok(out) if out.status.success() => PanelNotice::default(), Ok(out) => { @@ -367,11 +363,8 @@ impl SidePanel { .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW 不会隐藏 Tortoise GUI。 - } + // 只压掉它自己的控制台;TortoiseProc 的 GUI 窗口照旧出现。 + crate::platform::process::hidden_command(&mut command); match command.spawn() { Ok(_) => { self.set_op_error(String::new()); @@ -848,12 +841,8 @@ pub(crate) fn run_git( location: &str, timeout: Option, ) -> Option { - // Suppress the console window that `Command` flashes on Windows GUI apps. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } + // 从 GUI 进程起子进程时闪的那个控制台窗口,见 `platform::process`。 + crate::platform::process::hidden_command(&mut cmd); let out = match command_output_with_timeout(cmd, timeout) { Ok(output) => output, Err(error) => { @@ -1086,11 +1075,7 @@ pub(crate) fn read_svn_cli(root: &Path) -> Option { let mut cmd = Command::new("svn"); // 交互式认证提示会把无头子进程挂死;快照必须是非交互的。 cmd.arg("--non-interactive").args(args).current_dir(root); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } + crate::platform::process::hidden_command(&mut cmd); let out = cmd.output().ok()?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); diff --git a/nebula_app/src/git_worktree.rs b/nebula_app/src/git_worktree.rs index 00982641a..2222b0d08 100644 --- a/nebula_app/src/git_worktree.rs +++ b/nebula_app/src/git_worktree.rs @@ -284,8 +284,24 @@ impl WorktreeTransaction { } } +/// 本模块所有 `git` 子进程的统一构造口。 +/// +/// Pebrel 是 `windows_subsystem = "windows"` 的 GUI 进程(见 `main.rs`),本身没有 +/// 控制台可给子进程继承;不加 `CREATE_NO_WINDOW` 时 Windows 会给每条 git 命令分配 +/// 一个新控制台——在默认终端应用是 Windows Terminal 的机器上,那就是**每跑一条 +/// git 弹一扇窗口**。worktree 操作会连着跑好几条,用户看到的就是一串窗口。 +/// +/// 名字里的 `hidden` 不是装饰:`gpui_shell::code_tab` 里已有一个签名完全不同的 +/// `git_command(location, args)`,两个同名函数隔着模块互不相识,改错地方就是 +/// 又一次漏 flag。 +fn hidden_git_command() -> Command { + let mut command = Command::new("git"); + crate::platform::process::hidden_command(&mut command); + command +} + fn ensure_git_available() -> Result<(), ApiError> { - let output = Command::new("git").arg("--version").output().map_err(|error| { + let output = hidden_git_command().arg("--version").output().map_err(|error| { ApiError::new("git_unavailable", "Git executable is unavailable") .details(json!({ "reason": error.to_string() })) })?; @@ -305,7 +321,7 @@ fn validate_branch(branch: &str) -> Result<(), ApiError> { if branch.trim() != branch || branch.is_empty() || branch.chars().any(char::is_control) { return Err(ApiError::invalid_params("branch is not a valid Git branch name")); } - let output = Command::new("git") + let output = hidden_git_command() .args(["check-ref-format", "--branch", branch]) .output() .map_err(|error| { @@ -377,7 +393,7 @@ where I: IntoIterator, S: AsRef, { - Command::new("git").arg("-C").arg(cwd).args(args).output().map_err(|error| { + hidden_git_command().arg("-C").arg(cwd).args(args).output().map_err(|error| { ApiError::new("git_unavailable", "failed to execute Git") .details(json!({ "cwd": cwd, "reason": error.to_string() })) }) @@ -528,6 +544,6 @@ mod tests { } fn git_output(cwd: &Path, args: [&str; N]) -> Output { - Command::new("git").arg("-C").arg(cwd).args(args).output().expect("run git") + hidden_git_command().arg("-C").arg(cwd).args(args).output().expect("run git") } } diff --git a/nebula_app/src/gpui_shell/code_tab.rs b/nebula_app/src/gpui_shell/code_tab.rs index 9a3ad1800..ed672e485 100644 --- a/nebula_app/src/gpui_shell/code_tab.rs +++ b/nebula_app/src/gpui_shell/code_tab.rs @@ -655,7 +655,7 @@ fn read_worktree_file(key: &MergeKey) -> Result, String> { GitLocation::Wsl { distro, root } => { let path = join_guest_path(root, &key.relative_path); let mut command = Command::new("wsl.exe"); - let output = hidden_command(&mut command) + let output = crate::platform::process::hidden_command(&mut command) .args(["-d", distro, "--", "cat", "--", path.as_str()]) .output() .map_err(|error| format!("无法从 WSL 读取冲突文件: {error}"))?; @@ -677,7 +677,7 @@ fn write_conflict_result(key: &MergeKey, result: String) -> Result<(), String> { GitLocation::Wsl { distro, root } => { let path = join_guest_path(root, &key.relative_path); let mut command = Command::new("wsl.exe"); - let mut child = hidden_command(&mut command) + let mut child = crate::platform::process::hidden_command(&mut command) .args(["-d", distro, "--", "sh", "-c", "cat > \"$1\"", "nebula", path.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::null()) @@ -725,21 +725,12 @@ fn git_command(location: &GitLocation, args: &[&str]) -> Result &mut Command { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(0x0800_0000); - } - command -} - fn first_command_error(stderr: &[u8], fallback: &str) -> String { String::from_utf8_lossy(stderr) .lines() @@ -761,13 +752,15 @@ mod tests { use super::*; fn git(root: &Path, args: &[&str]) -> std::process::Output { - Command::new("git") + // 测试二进制没有控制台:不加这个 flag,每条 git 都会在用户屏幕上弹一个 + // 终端窗口(见 `platform::process`)。 + let mut command = Command::new("git"); + command .arg("-C") .arg(root) .args(["-c", "user.name=Nebula Test", "-c", "user.email=nebula@example.invalid"]) - .args(args) - .output() - .expect("run git") + .args(args); + crate::platform::process::hidden_command(&mut command).output().expect("run git") } fn commit(root: &Path, message: &str) { diff --git a/nebula_app/src/gpui_shell/terminal/view/image_paste.rs b/nebula_app/src/gpui_shell/terminal/view/image_paste.rs index 561eb4120..279705222 100644 --- a/nebula_app/src/gpui_shell/terminal/view/image_paste.rs +++ b/nebula_app/src/gpui_shell/terminal/view/image_paste.rs @@ -286,14 +286,13 @@ async fn stage_image(target: ImageTarget, png: Vec) -> Result, path: &Path) -> Result { - use std::os::windows::process::CommandExt as _; use std::time::Duration; let system = std::env::var_os("SystemRoot").ok_or("Windows system directory is unavailable")?; let executable = Path::new(&system).join("System32").join("wsl.exe"); let command = || { let mut command = std::process::Command::new(&executable); - command.creation_flags(0x0800_0000); + crate::platform::process::hidden_command(&mut command); if let Some(distro) = distro { command.args(["--distribution", distro]); } diff --git a/nebula_app/src/gpui_shell/terminal/view/notifications.rs b/nebula_app/src/gpui_shell/terminal/view/notifications.rs index 66b8e4923..35c78f780 100644 --- a/nebula_app/src/gpui_shell/terminal/view/notifications.rs +++ b/nebula_app/src/gpui_shell/terminal/view/notifications.rs @@ -20,11 +20,21 @@ impl super::TerminalView { } } -pub(super) fn screen_notification(previous: AgentStatus, next: AgentStatus) -> Option { +/// 屏幕推导出的状态变化要不要弹提示。 +/// +/// `hooks` 表示当前 agent 会话是否已经收到 hook。有 hook 时,完成通知应等待 +/// `TurnDone`。看门狗连续识别到 Idle 界面也会把 Working / Blocked 降为 Done, +/// 但界面可能在后台任务仍运行时显示 Idle,不能据此宣称回合完成。 +/// 这里仅控制通知;调用方仍保留屏幕状态自愈,无 hook 会话保留屏幕通知。 +pub(super) fn screen_notification( + previous: AgentStatus, + next: AgentStatus, + hooks: bool, +) -> Option { match next { AgentStatus::Blocked if previous != AgentStatus::Blocked => Some(true), AgentStatus::Done if matches!(previous, AgentStatus::Working | AgentStatus::Blocked) => { - Some(false) + (!hooks).then_some(false) }, _ => None, } @@ -54,14 +64,39 @@ mod tests { #[test] fn screen_completion_and_attention_are_edges_not_idle_polling() { - assert_eq!(screen_notification(AgentStatus::Working, AgentStatus::Done), Some(false)); - assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Done), Some(false)); - assert_eq!(screen_notification(AgentStatus::Working, AgentStatus::Blocked), Some(true)); - assert_eq!(screen_notification(AgentStatus::Unknown, AgentStatus::Blocked), Some(true)); - assert_eq!(screen_notification(AgentStatus::Done, AgentStatus::Done), None); - assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Blocked), None); - assert_eq!(screen_notification(AgentStatus::Unknown, AgentStatus::Idle), None); - assert_eq!(screen_notification(AgentStatus::Idle, AgentStatus::Idle), None); + // 无 hook 时屏幕是唯一证据,「完成」照旧提示。 + assert_eq!( + screen_notification(AgentStatus::Working, AgentStatus::Done, false), + Some(false) + ); + assert_eq!( + screen_notification(AgentStatus::Blocked, AgentStatus::Done, false), + Some(false) + ); + assert_eq!( + screen_notification(AgentStatus::Working, AgentStatus::Blocked, false), + Some(true) + ); + assert_eq!( + screen_notification(AgentStatus::Unknown, AgentStatus::Blocked, false), + Some(true) + ); + assert_eq!(screen_notification(AgentStatus::Done, AgentStatus::Done, false), None); + assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Blocked, false), None); + assert_eq!(screen_notification(AgentStatus::Unknown, AgentStatus::Idle, false), None); + assert_eq!(screen_notification(AgentStatus::Idle, AgentStatus::Idle, false), None); + } + + /// hook 在场时,屏幕推断的 Done 不能触发完成通知。 + #[test] + fn screen_inferred_completion_stays_silent_while_hooks_are_live() { + assert_eq!(screen_notification(AgentStatus::Working, AgentStatus::Done, true), None); + assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Done, true), None); + // 等输入是真事件,与完成无关,不受 hook 影响。 + assert_eq!( + screen_notification(AgentStatus::Working, AgentStatus::Blocked, true), + Some(true) + ); } #[test] diff --git a/nebula_app/src/gpui_shell/terminal/view/path_drop.rs b/nebula_app/src/gpui_shell/terminal/view/path_drop.rs index 4cb30d1b6..1765acd21 100644 --- a/nebula_app/src/gpui_shell/terminal/view/path_drop.rs +++ b/nebula_app/src/gpui_shell/terminal/view/path_drop.rs @@ -170,6 +170,11 @@ fn wsl_paths( user: Option, ) -> Result, String> { let mut command = std::process::Command::new("wsl.exe"); + // `command_output_with_timeout` 只负责超时和管道,压窗口得调用方自己来 + // (见 `platform::process`):不压的话,往 WSL pane 拖一次文件就等于从无 + // 控制台的 GUI 进程起一个新控制台——默认终端是 Windows Terminal 的机器上 + // 会弹一整扇窗口。 + crate::platform::process::hidden_command(&mut command); if let Some(distro) = distro { command.args(["-d", &distro]); } diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index d58f39c34..26ae8b03e 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -1150,9 +1150,11 @@ impl TerminalView { }; self.confirmation.observe_waiting(next == AgentStatus::Blocked); if next != self.agent_status { - if let Some(attention) = - super::notifications::screen_notification(self.agent_status, next) - { + if let Some(attention) = super::notifications::screen_notification( + self.agent_status, + next, + self.agent_hook_seen, + ) { cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { program: program.clone(), message: None, diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs index 7cc152915..8936d61c6 100644 --- a/nebula_app/src/platform/ai_session_identity.rs +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -193,11 +193,7 @@ fn probe_wsl( ) -> Option { let mut command = Command::new("wsl.exe"); command.args(wsl_probe_args(distro, user, pane_id, instance)); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } + crate::platform::process::hidden_command(&mut command); run_probe_command(command) } diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 06074efda..44f58dc45 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -26,6 +26,7 @@ pub mod fonts; pub(crate) mod keyboard; pub mod notifications; pub(crate) mod pi_session; +pub(crate) mod process; pub(crate) mod process_snapshot; pub mod shell; pub mod shell_integration; diff --git a/nebula_app/src/platform/process.rs b/nebula_app/src/platform/process.rs new file mode 100644 index 000000000..5e94f0768 --- /dev/null +++ b/nebula_app/src/platform/process.rs @@ -0,0 +1,98 @@ +//! 从本进程启动控制台子进程时的统一抑制入口。 +//! +//! Pebrel 是 `windows_subsystem = "windows"` 的 GUI 进程(见 `main.rs`), +//! **自己没有控制台可以给子进程继承**;`cargo test --bin pebrel` 的测试二进制 +//! 从同一个 crate root 编出来,同样没有。于是任何没带 `CREATE_NO_WINDOW` 的 +//! 控制台子进程(`git`、`ssh -G`、`wsl.exe`…)都会被 Windows 分配一个新控制台 +//! ——在默认终端应用是 Windows Terminal 的机器上,那就是**用户屏幕上弹一整扇 +//! 窗口**。2026-09-14 实测:整跑一次测试弹出 86 个窗口。 +//! +//! 用法:构造完参数、`spawn()` 之前过一道。 +//! +//! ```ignore +//! let mut command = Command::new("git"); +//! command.args(["status"]); +//! crate::platform::process::hidden_command(&mut command).output()?; +//! ``` +//! +//! **不要**给需要与用户交互的子进程加这个(`pebrel ssh ` 的交互会话就 +//! 靠继承父控制台工作,见 `ssh::run`);同理,`notepad` / `explorer` / `open` +//! 那几处是**故意**要给用户看见窗口的。 + +use std::process::Command; + +/// `CREATE_NO_WINDOW` 的**唯一定义处**。 +/// +/// 需要它的地方几乎都该直接调 [`hidden_command`];把这个常量单独导出,是为了 +/// 那些 `std::process::Command` 之外的类型——`tokio::process::Command` +/// (`ssh_proxy.rs` 的代理命令)不是同一个类型,只能自己 `creation_flags`, +/// 但至少取值仍然只有这一个来源。别再写 `0x0800_0000`。 +#[cfg(windows)] +pub(crate) const CREATE_NO_WINDOW: u32 = windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; + +/// 抑制子进程的控制台窗口;非 Windows 上是空操作。 +pub(crate) fn hidden_command(command: &mut Command) -> &mut Command { + hidden_command_with(command, 0) +} + +/// 同 [`hidden_command`],但额外叠加调用方自己的创建标志。 +/// +/// `creation_flags` 是**整体替换**而不是按位或,所以调用方给的 `extra_flags` +/// 必须自己带全(例如 daemon 需要的 `CREATE_NEW_PROCESS_GROUP`);本函数负责 +/// 保证 `CREATE_NO_WINDOW` 一定在里面。 +pub(crate) fn hidden_command_with(command: &mut Command, extra_flags: u32) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(CREATE_NO_WINDOW | extra_flags); + } + #[cfg(not(windows))] + let _ = extra_flags; + command +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::io::Write as _; + use std::process::Stdio; + + #[test] + fn hidden_console_children_keep_pipes_and_exit_status() { + // Query the console from a real console-subsystem executable. The GPUI + // test executable itself is a GUI process and cannot expose this bug. + let script = r#" +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; +public static class ConsoleProbe { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +}' +if ([ConsoleProbe]::GetConsoleWindow() -ne [IntPtr]::Zero) { exit 91 } +[Console]::Out.Write([Console]::In.ReadLine()) +[Console]::Error.Write('probe-stderr') +exit 7 +"#; + let system = std::env::var_os("SystemRoot").expect("Windows system directory"); + let powershell = + std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe"); + for extra_flags in [0, windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP] { + let mut command = Command::new(&powershell); + command + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if extra_flags == 0 { + hidden_command(&mut command); + } else { + hidden_command_with(&mut command, extra_flags); + } + let mut child = command.spawn().expect("start console probe"); + child.stdin.take().unwrap().write_all(b"probe-stdin\n").unwrap(); + let output = child.wait_with_output().expect("wait for console probe"); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert_eq!(output.stdout, b"probe-stdin"); + assert_eq!(output.stderr, b"probe-stderr"); + } + } +} diff --git a/nebula_app/src/runtime_api/tests.rs b/nebula_app/src/runtime_api/tests.rs index c4684da88..21a5736c1 100644 --- a/nebula_app/src/runtime_api/tests.rs +++ b/nebula_app/src/runtime_api/tests.rs @@ -437,11 +437,15 @@ fn agent_fork_rolls_back_when_ui_launch_fails() { assert!(!response.ok); assert_eq!(response.error.unwrap().code, "action_failed"); assert!(!target.exists()); + let mut branch_query = std::process::Command::new("git"); + branch_query.arg("-C").arg(repository.path()).args([ + "show-ref", + "--verify", + "--quiet", + "refs/heads/nebula/failed-agent", + ]); assert!( - !std::process::Command::new("git") - .arg("-C") - .arg(repository.path()) - .args(["show-ref", "--verify", "--quiet", "refs/heads/nebula/failed-agent"]) + !crate::platform::process::hidden_command(&mut branch_query) .status() .expect("query branch") .success() @@ -489,12 +493,11 @@ fn managed_agent_keeps_worktree_provenance() { fn test_git_repository() -> tempfile::TempDir { let directory = tempfile::tempdir().expect("create repository directory"); let git = |args: &[&str]| { - std::process::Command::new("git") - .arg("-C") - .arg(directory.path()) - .args(args) - .output() - .expect("run git") + // 测试二进制没有控制台,不压掉就会在用户屏幕上弹窗口(见 + // `platform::process`)。 + let mut command = std::process::Command::new("git"); + command.arg("-C").arg(directory.path()).args(args); + crate::platform::process::hidden_command(&mut command).output().expect("run git") }; assert!(git(&["init", "--initial-branch=main"]).status.success()); std::fs::write(directory.path().join("tracked.txt"), "tracked").expect("write tracked file"); diff --git a/nebula_app/src/runtime_exec.rs b/nebula_app/src/runtime_exec.rs index c13b672fb..a02d19666 100644 --- a/nebula_app/src/runtime_exec.rs +++ b/nebula_app/src/runtime_exec.rs @@ -364,7 +364,21 @@ fn configure_process_group(command: &mut Command) { command.process_group(0); } -#[cfg(not(unix))] +/// Windows:`pane.exec` 的子进程绝不允许弹出控制台窗口。 +/// +/// Pebrel 自己是 `windows_subsystem = "windows"` 的 GUI 进程(见 `main.rs`), +/// **没有控制台**可给子进程继承;不抑制的话 Windows 会给每条 exec 命令分配一个 +/// 新控制台,而默认终端应用是 Windows Terminal 的机器上那就是**弹一整扇窗口** +/// (同 [`crate::ssh_session`] 里 `ssh.exe -G` 那条注释说的现象)。 +/// +/// exec 的 stdin 是 null、stdout/stderr 走管道,从头到尾没有交互,也就不需要 +/// 控制台——和 wsl/git 那些 spawn 用 `CREATE_NO_WINDOW` 是同一条规矩。 +#[cfg(windows)] +fn configure_process_group(command: &mut Command) { + crate::platform::process::hidden_command(command); +} + +#[cfg(not(any(unix, windows)))] fn configure_process_group(_: &mut Command) {} struct ProcessGroup { diff --git a/nebula_app/src/ssh.rs b/nebula_app/src/ssh.rs index 2a12ba873..b4b61f323 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -338,7 +338,13 @@ fn cli_verdict(args: &[String]) -> CliVerdict { /// (inject) if ssh can't run or errors, so a config quirk never blocks a login. #[cfg(windows)] fn config_forces_passthrough(ssh: &str, args: &[String]) -> bool { - let out = std::process::Command::new(ssh).arg("-G").args(args).output(); + let mut command = std::process::Command::new(ssh); + // 这是个非交互探针(`-G` 只解析配置、不连接),和 `ssh_session.rs` 里那条 + // 同源:不压掉就会从无控制台的 GUI 进程里闪出一个新控制台(见 + // `platform::process`)。`pebrel ssh ` 的**交互会话**是另一回事,它 + // 靠 `AttachConsole` 继承父控制台,绝不能压。 + crate::platform::process::hidden_command(&mut command); + let out = command.arg("-G").args(args).output(); match out { Ok(o) if o.status.success() => { parse_g_says_passthrough(&String::from_utf8_lossy(&o.stdout)) diff --git a/nebula_app/src/ssh_proxy.rs b/nebula_app/src/ssh_proxy.rs index 24c974026..7f608d8a7 100644 --- a/nebula_app/src/ssh_proxy.rs +++ b/nebula_app/src/ssh_proxy.rs @@ -317,7 +317,9 @@ pub async fn connect_command( let mut command = { let mut command = tokio::process::Command::new("cmd.exe"); command.args(["/D", "/S", "/C", &rendered]); - command.creation_flags(0x08000000); + // `tokio::process::Command` 不是 `std::process::Command`,用不了 + // `platform::process::hidden_command`;flag 取值仍只有那一个来源。 + command.creation_flags(crate::platform::process::CREATE_NO_WINDOW); command }; #[cfg(not(windows))] diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 1a2b0502d..d8f4ca802 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -395,11 +395,7 @@ fn ssh_config_command(target: &str, config: Option<&Path>) -> Command { command.arg("-F").arg(path); } command.arg("-G").arg("--").arg(target); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); - } + crate::platform::process::hidden_command(&mut command); command }