From 2b4eadcdeefcd72c64335bc2cd0cdfbd996403ab Mon Sep 17 00:00:00 2001 From: AnxForever <130662349+AnxForever@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:54:29 +0800 Subject: [PATCH 1/4] fix(ai-hook): keep the turn-done notification off background work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Stop` fires at the end of every turn, not at task completion, so the guard that suppresses the completion toast while work is still in flight is what keeps that notification honest. It reads `background_tasks`, but the counter only accepted entries whose type was `subagent` — and Claude Code reports a backgrounded Bash command as `local_bash`. The most common shape (start a long command, turn ends, command still running) therefore counted zero: the guard stayed off and the toast claimed the turn was done while the command was still running. Count every entry the field carries. It is documented as in-flight work ("Empty array when nothing is in flight"), so the filter belongs on the status, not on the type. Idle `in_process_teammate` entries stay excluded — they keep reporting `status: "running"` after they finish (anthropics/claude-code#85955) — matching Claude Code's own `status === "running" && !isIdle` predicate. Tests: three cases pin the counting (every in-flight type, an empty array leaving the turn done, an idle teammate not holding it open). The first one counted zero before this change. --- nebula_app/src/ai_hook.rs | 165 ++++++++++++++++++++++++++++++++++---- 1 file changed, 151 insertions(+), 14 deletions(-) diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 6f09e1e8e..a3f2870b6 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -867,6 +867,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) => { @@ -875,21 +887,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); } @@ -898,6 +903,79 @@ 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` …)。 +/// +/// 判反的代价不对称:漏掉一个终态,`active` 会永远大于 0,pane 停在「工作中」、 +/// 完成通知不再弹,`runtime_api` 那边连 `agent.delegate` 的完成回调都不会触发—— +/// 比早弹一条通知更难发现。多留的词最多只是让一笔刚结束的活儿多算一拍。 +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()?; @@ -1177,18 +1255,77 @@ 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); + } + + /// 闲置的 `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"] { From be5fd1eb670ef65e134f9c7ec3b9b3b141a77842 Mon Sep 17 00:00:00 2001 From: AnxForever <130662349+AnxForever@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:57:27 +0800 Subject: [PATCH 2/4] fix(windows): stop console children from popping terminal windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pebrel is a `windows_subsystem = "windows"` process, so it has no console to hand to a child, and Windows allocates a new one for every console program we spawn. When the default terminal application is Windows Terminal that is not a hidden flash — it is a whole window on screen. Measured on the test binary, which has no console for the same reason: one full `cargo test` run popped 86 windows, and the `runtime_api` module alone popped 7. Most spawn sites already carried `CREATE_NO_WINDOW`, but each spelled it itself — `0x0800_0000` in some places, the named constant in others, a private `hidden_command` in one module. Three sites had no flag at all: `pane.exec` (`runtime_exec::configure_process_group` was a no-op on Windows), the WSL path of drag-and-drop (`path_drop::wsl_paths`), and the `ssh -G` probe behind the `pebrel ssh` entry point. Route every console child through one helper, `platform::process`: - `hidden_command` for the common case; - `hidden_command_with` when the caller needs extra creation flags (the daemon's `CREATE_NEW_PROCESS_GROUP`) — `creation_flags` replaces rather than ORs, so the helper owns the `CREATE_NO_WINDOW` bit; - a `CREATE_NO_WINDOW` constant for `Command` types that are not `std::process::Command` (the tokio proxy command). No raw literals left. Test helpers are included: the test binary is console-less too, so its git calls popped windows as well (7 during one `runtime_api` run, 0 after). Deliberately left alone, and now documented as such: the interactive `pebrel ssh ` session (it inherits the parent console through `AttachConsole`), the update installer wizard, and `notepad`/`explorer`/ `open`, which exist to be visible. --- nebula_app/src/daemon.rs | 20 +++---- nebula_app/src/display/command_completion.rs | 21 ++++---- .../src/display/side_panel/enumerate.rs | 6 +-- .../src/display/side_panel/gitignore.rs | 6 +-- .../src/display/side_panel/gitignore_tests.rs | 8 +-- nebula_app/src/display/side_panel/mod.rs | 6 +-- nebula_app/src/display/side_panel/vcs.rs | 27 +++------- nebula_app/src/git_worktree.rs | 24 +++++++-- nebula_app/src/gpui_shell/code_tab.rs | 25 ++++----- .../gpui_shell/terminal/view/image_paste.rs | 3 +- .../src/gpui_shell/terminal/view/path_drop.rs | 5 ++ .../src/platform/ai_session_identity.rs | 6 +-- nebula_app/src/platform/mod.rs | 1 + nebula_app/src/platform/process.rs | 53 +++++++++++++++++++ nebula_app/src/runtime_api/tests.rs | 21 ++++---- nebula_app/src/runtime_exec.rs | 16 +++++- nebula_app/src/ssh.rs | 8 ++- nebula_app/src/ssh_proxy.rs | 4 +- nebula_app/src/ssh_session.rs | 6 +-- nebula_app/src/update_check.rs | 7 +-- 20 files changed, 158 insertions(+), 115 deletions(-) create mode 100644 nebula_app/src/platform/process.rs 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..5a5e566b2 100644 --- a/nebula_app/src/display/side_panel/gitignore_tests.rs +++ b/nebula_app/src/display/side_panel/gitignore_tests.rs @@ -2,10 +2,12 @@ use super::*; fn repository() -> tempfile::TempDir { let directory = tempfile::tempdir().unwrap(); + // 测试二进制没有控制台:不压掉这个 flag,每次 `git init` 都会在用户屏幕上 + // 弹一个终端窗口(见 `platform::process`)。 + let mut command = std::process::Command::new("git"); + command.args(["init", "-q"]).arg(directory.path()); assert!( - std::process::Command::new("git") - .args(["init", "-q"]) - .arg(directory.path()) + crate::platform::process::hidden_command(&mut command) .status() .unwrap() .success() diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index 5a5f90dc2..6c74b4678 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -1294,11 +1294,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 86244135d..ce7b9e846 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()); @@ -850,12 +843,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) => { @@ -1088,11 +1077,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 9d6ef6153..e661f5c59 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/path_drop.rs b/nebula_app/src/gpui_shell/terminal/view/path_drop.rs index bb60ca1ad..ff658ec15 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/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs index 31fc9e73f..34f35ef65 100644 --- a/nebula_app/src/platform/ai_session_identity.rs +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -173,11 +173,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 868374ff3..e2d64477b 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -24,6 +24,7 @@ pub mod fonts; #[cfg(all(windows, feature = "gpui-shell"))] pub(crate) mod keyboard; pub mod notifications; +pub(crate) mod process; pub mod shell; pub mod shell_integration; pub mod startup; diff --git a/nebula_app/src/platform/process.rs b/nebula_app/src/platform/process.rs new file mode 100644 index 000000000..451a54443 --- /dev/null +++ b/nebula_app/src/platform/process.rs @@ -0,0 +1,53 @@ +//! 从本进程启动控制台子进程时的统一抑制入口。 +//! +//! 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 +} diff --git a/nebula_app/src/runtime_api/tests.rs b/nebula_app/src/runtime_api/tests.rs index c4684da88..0b2f13d2f 100644 --- a/nebula_app/src/runtime_api/tests.rs +++ b/nebula_app/src/runtime_api/tests.rs @@ -437,11 +437,13 @@ 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 +491,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 4f6a4741a..629605a4d 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 f2eef4e5a..515fd4cdf 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -295,7 +295,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 179d246d2..5f66dc7bd 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -366,11 +366,7 @@ fn find_ssh() -> PathBuf { fn ssh_config_output(target: &str) -> io::Result { let mut command = Command::new(find_ssh()); 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.output() } diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index d6ad5fff5..179aac90a 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -271,12 +271,7 @@ fn fetch_latest_release() -> Result { "Accept: application/vnd.github+json", RELEASES_API, ]); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } + crate::platform::process::hidden_command(&mut command); let output = command.output().map_err(|error| format!("无法启动 curl:{error}"))?; if !output.status.success() { let detail = String::from_utf8_lossy(&output.stderr); From 25cac197d57a10723430e4348d8e64edcbf63d6d Mon Sep 17 00:00:00 2001 From: AnxForever <130662349+AnxForever@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:46:35 +0800 Subject: [PATCH 3/4] fix(gpui): stop screen silence from toasting "turn complete" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen watchdog downgrades `Working -> Done` after the pane's screen has been static for 5 ticks (it samples at 1 Hz), and that transition used to notify unconditionally. But a static screen is not evidence that the agent stopped: Claude Code prints nothing while it thinks, and a long command such as a full build writes nothing for minutes. The result was a "回合完成" toast while the task was still running. The screen is only a fallback for when the hook never arrives. When hooks are live, the agent CLI's own `TurnDone` is the authoritative terminal state, so keep the screen-derived transition (the sidebar dot still updates) but stay silent about it. Clients without hooks keep the old behaviour, where the screen really is the only evidence. This is the notification half of the problem. The other half is that `active_background_tasks` never fires for Claude Code — its `Stop` payload carries no `background_tasks` field — so the guard at the top of the `TurnDone` arm is dead code for that CLI. Left as-is for now. --- .../gpui_shell/terminal/view/notifications.rs | 41 ++++++++++++++----- .../src/gpui_shell/terminal/view/runtime.rs | 2 +- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/nebula_app/src/gpui_shell/terminal/view/notifications.rs b/nebula_app/src/gpui_shell/terminal/view/notifications.rs index 66b8e4923..482a8d1fa 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 CLI 的 hook 是否在场。在场时 `TurnDone` 才是权威终态, +/// 屏幕静默只是「迟迟没等到 hook」的兜底猜测——它可以照旧更新状态(侧栏蓝点 +/// 不受影响,状态赋值在调用方),但不该对用户宣称「回合完成」。 +/// +/// 2026-09-14:此前无条件对 `Working -> Done` 弹提示,而这条转换只要屏幕静默 +/// 5 拍(看门狗 1 Hz)就会发生。Claude 思考时、或跑一条不输出的长命令(编译等) +/// 时终端本就没有输出,于是任务还在跑却反复弹「回合完成」。没有 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,25 @@ 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 在场时 `TurnDone` 才是权威终态:屏幕静默推出的 `Done` 不弹提示, + /// 否则 Claude 思考或长命令运行期间会反复误报「回合完成」。 + #[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/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index d58f39c34..2b98838d5 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -1151,7 +1151,7 @@ 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) + super::notifications::screen_notification(self.agent_status, next, self.agent_hook_seen) { cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { program: program.clone(), From bfa5d47339a3f128f2283637a66b1ef5c61369ba Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 17 Sep 2026 12:31:59 +0800 Subject: [PATCH 4/4] test: verify notification guards and hidden Windows child processes --- nebula_app/src/ai_hook.rs | 30 ++++++++---- .../src/display/side_panel/gitignore_tests.rs | 7 +-- .../gpui_shell/terminal/view/notifications.rs | 46 ++++++++++++------- .../src/gpui_shell/terminal/view/runtime.rs | 8 ++-- nebula_app/src/platform/process.rs | 45 ++++++++++++++++++ nebula_app/src/runtime_api/tests.rs | 10 ++-- 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 32b753dcb..9b5d8b0f6 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -932,9 +932,8 @@ fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) { /// `stopped` / `cancelled` / `aborted` / `exited`。这里取其中的终态,再补上同族词 /// (`done` / `finished` / `error` / `canceled` …)。 /// -/// 判反的代价不对称:漏掉一个终态,`active` 会永远大于 0,pane 停在「工作中」、 -/// 完成通知不再弹,`runtime_api` 那边连 `agent.delegate` 的完成回调都不会触发—— -/// 比早弹一条通知更难发现。多留的词最多只是让一笔刚结束的活儿多算一拍。 +/// 漏掉终态会让 pane 一直等待后台任务;误把运行态加入这张表则会提前通知完成。 +/// 缺失或未知状态按仍在运行处理,避免 provider 扩展类型时恢复提前完成的问题。 const TERMINAL_TASK_STATUSES: &[&str] = &[ "completed", "complete", @@ -977,9 +976,7 @@ fn task_is_in_flight(task: &serde_json::Map) -> bool { return false; } match task.get("status").and_then(Value::as_str) { - Some(status) => { - !TERMINAL_TASK_STATUSES.contains(&status.to_ascii_lowercase().as_str()) - }, + Some(status) => !TERMINAL_TASK_STATUSES.contains(&status.to_ascii_lowercase().as_str()), None => true, } } @@ -1307,6 +1304,24 @@ mod remote_tests { 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 会永远停在「还在跑」,完成通知再也弹不出来。 @@ -1323,8 +1338,7 @@ mod remote_tests { /// `agent.delegate` 的完成回调也不会触发,比早弹一条更难发现。 #[test] fn finished_background_tasks_do_not_hold_the_turn_open() { - for status in ["completed", "success", "exited", "failed", "killed", "cancelled", "idle"] - { + for status in ["completed", "success", "exited", "failed", "killed", "cancelled", "idle"] { let payload = serde_json::json!({ "session_id": "s", "hook_event_name": "Stop", diff --git a/nebula_app/src/display/side_panel/gitignore_tests.rs b/nebula_app/src/display/side_panel/gitignore_tests.rs index 5a5e566b2..413a73b9e 100644 --- a/nebula_app/src/display/side_panel/gitignore_tests.rs +++ b/nebula_app/src/display/side_panel/gitignore_tests.rs @@ -6,12 +6,7 @@ fn repository() -> tempfile::TempDir { // 弹一个终端窗口(见 `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() - ); + assert!(crate::platform::process::hidden_command(&mut command).status().unwrap().success()); directory } diff --git a/nebula_app/src/gpui_shell/terminal/view/notifications.rs b/nebula_app/src/gpui_shell/terminal/view/notifications.rs index 482a8d1fa..35c78f780 100644 --- a/nebula_app/src/gpui_shell/terminal/view/notifications.rs +++ b/nebula_app/src/gpui_shell/terminal/view/notifications.rs @@ -22,15 +22,15 @@ impl super::TerminalView { /// 屏幕推导出的状态变化要不要弹提示。 /// -/// `hooks` 表示 agent CLI 的 hook 是否在场。在场时 `TurnDone` 才是权威终态, -/// 屏幕静默只是「迟迟没等到 hook」的兜底猜测——它可以照旧更新状态(侧栏蓝点 -/// 不受影响,状态赋值在调用方),但不该对用户宣称「回合完成」。 -/// -/// 2026-09-14:此前无条件对 `Working -> Done` 弹提示,而这条转换只要屏幕静默 -/// 5 拍(看门狗 1 Hz)就会发生。Claude 思考时、或跑一条不输出的长命令(编译等) -/// 时终端本就没有输出,于是任务还在跑却反复弹「回合完成」。没有 hook 的客户端 -/// 屏幕仍是唯一证据,保持原样。 -pub(super) fn screen_notification(previous: AgentStatus, next: AgentStatus, hooks: bool) -> 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) => { @@ -65,24 +65,38 @@ mod tests { #[test] fn screen_completion_and_attention_are_edges_not_idle_polling() { // 无 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::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 在场时 `TurnDone` 才是权威终态:屏幕静默推出的 `Done` 不弹提示, - /// 否则 Claude 思考或长命令运行期间会反复误报「回合完成」。 + /// 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)); + assert_eq!( + screen_notification(AgentStatus::Working, AgentStatus::Blocked, true), + Some(true) + ); } #[test] diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 2b98838d5..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, self.agent_hook_seen) - { + 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/process.rs b/nebula_app/src/platform/process.rs index 451a54443..5e94f0768 100644 --- a/nebula_app/src/platform/process.rs +++ b/nebula_app/src/platform/process.rs @@ -51,3 +51,48 @@ pub(crate) fn hidden_command_with(command: &mut Command, extra_flags: u32) -> &m 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 0b2f13d2f..21a5736c1 100644 --- a/nebula_app/src/runtime_api/tests.rs +++ b/nebula_app/src/runtime_api/tests.rs @@ -438,10 +438,12 @@ fn agent_fork_rolls_back_when_ui_launch_fails() { 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"]); + branch_query.arg("-C").arg(repository.path()).args([ + "show-ref", + "--verify", + "--quiet", + "refs/heads/nebula/failed-agent", + ]); assert!( !crate::platform::process::hidden_command(&mut branch_query) .status()