Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 165 additions & 14 deletions nebula_app/src/ai_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,18 @@ fn background_task_summary(payload: &Value) -> Option<AiBackgroundTasks> {
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) => {
Expand All @@ -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);
}
Expand All @@ -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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String> {
let sanitized = sanitize_json(payload, 0);
let raw = serde_json::to_string(&sanitized).ok()?;
Expand Down Expand Up @@ -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"] {
Expand Down
20 changes: 7 additions & 13 deletions nebula_app/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -38,16 +36,12 @@ where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
{
// 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(|_| ())
}
Expand Down
21 changes: 9 additions & 12 deletions nebula_app/src/display/command_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,15 @@ fn nebula_collect_commands() -> Vec<String> {
/// commands as the available completion source.
#[cfg(windows)]
fn nebula_powershell_commands() -> Vec<String> {
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)
Expand Down
6 changes: 1 addition & 5 deletions nebula_app/src/display/side_panel/enumerate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,7 @@ pub(crate) fn run_wsl_find_lenient(
) -> Option<(Vec<u8>, 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) => {
Expand Down
6 changes: 1 addition & 5 deletions nebula_app/src/display/side_panel/gitignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,7 @@ fn matching_rule(root: &Path, path: &Path) -> Result<Option<IgnoreRule>, 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();
Expand Down
13 changes: 5 additions & 8 deletions nebula_app/src/display/side_panel/gitignore_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 1 addition & 5 deletions nebula_app/src/display/side_panel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading