fix: stop claude subagent completion reviving working

refs #198
This commit is contained in:
Ogulcan Celik 2026-05-20 20:42:14 +03:00
parent 05e44d0039
commit f7b26a9117
4 changed files with 60 additions and 31 deletions

View File

@ -10,6 +10,7 @@
- Added native Kiro CLI detection with idle and working state heuristics. (#185)
### Fixed
- The Claude Code integration no longer lets subagent completion hooks report durable `working`, preventing delayed recap or subagent completion events from reviving an idle pane. (#198)
- Remote clients now bridge local clipboard images into the remote pane by staging them as temporary image files and pasting the remote path, so Claude Code image paste works over `herdr --remote`. (#205)
### Breaking Changes

View File

@ -2,7 +2,7 @@
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=claude
# HERDR_INTEGRATION_VERSION=2
# HERDR_INTEGRATION_VERSION=3
set -eu
@ -47,9 +47,16 @@ if hook_input_file:
except Exception:
hook_input = {}
hook_event_name = str(hook_input.get("hook_event_name") or "")
is_subagent = bool(hook_input.get("agent_id"))
if hook_event_name == "SubagentStop":
# SubagentStop is a completion event. Older Herdr integrations mapped it
# to durable working, but Claude recap/away-summary can emit it after the
# main turn has already stopped. Never let it revive an idle pane.
raise SystemExit(0)
if is_subagent and action in ("idle", "release"):
action = "working"
# Subagent completion must not make the parent pane look done early.
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()

View File

@ -16,7 +16,7 @@ const PI_INTEGRATION_VERSION: u32 = 1;
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
const CLAUDE_INTEGRATION_VERSION: u32 = 2;
const CLAUDE_INTEGRATION_VERSION: u32 = 3;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
@ -591,6 +591,11 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
"PostToolUseFailure",
&format!("bash {quoted_hook_path} working"),
)?;
remove_command_hook(
hooks,
"SubagentStop",
&format!("bash {quoted_hook_path} working"),
)?;
ensure_command_hook(
hooks,
"UserPromptSubmit",
@ -612,13 +617,6 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"SubagentStop",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"Stop",
@ -1680,10 +1678,7 @@ mod tests {
);
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(settings["hooks"].get("SubagentStop").is_none());
assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
@ -1749,10 +1744,7 @@ mod tests {
);
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert_eq!(
settings["hooks"]["SubagentStop"].as_array().unwrap().len(),
1
);
assert!(settings["hooks"].get("SubagentStop").is_none());
assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1);
assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1);
@ -1761,7 +1753,7 @@ mod tests {
}
#[test]
fn install_claude_removes_deprecated_post_tool_hooks_and_preserves_user_hooks() {
fn install_claude_removes_deprecated_completion_hooks_and_preserves_user_hooks() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@ -1772,7 +1764,8 @@ mod tests {
fs::write(
claude_dir.join("settings.json"),
format!(
r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}]}}}}"#,
r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}]}}}}"#,
hook_path.display(),
hook_path.display(),
hook_path.display(),
),
@ -1793,6 +1786,10 @@ mod tests {
settings["hooks"]["PostToolUseFailure"][0]["hooks"][0]["command"],
"echo keep-failure"
);
assert_eq!(
settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"],
"echo keep-subagent"
);
assert_eq!(
settings["hooks"]["UserPromptSubmit"]
.as_array()
@ -1830,7 +1827,37 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(1));
assert_eq!(claude.expected_version, 2);
assert_eq!(claude.expected_version, 3);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn claude_v2_integration_status_is_outdated() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let claude_hooks_dir = home.join(".claude").join("hooks");
fs::create_dir_all(&claude_hooks_dir).unwrap();
let hook_path = claude_hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
fs::write(
&hook_path,
"#!/bin/sh\n# HERDR_INTEGRATION_ID=claude\n# HERDR_INTEGRATION_VERSION=2\n",
)
.unwrap();
std::env::set_var("HOME", &home);
let statuses = installed_integration_statuses();
let claude = statuses
.iter()
.find(|status| status.target == crate::api::schema::IntegrationTarget::Claude)
.unwrap();
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
assert_eq!(claude.expected_version, 3);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");

View File

@ -461,19 +461,13 @@ fn claude_hook_reports_subagent_working_and_blocked() {
}
#[test]
fn claude_hook_converts_subagent_idle_and_release_to_working() {
fn claude_hook_ignores_subagent_completion_reports() {
let subagent_input =
r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#;
let idle = run_claude_hook("idle", subagent_input)
.expect("subagent idle should keep parent pane working");
assert_eq!(idle["method"], "pane.report_agent");
assert_eq!(idle["params"]["state"], "working");
let release = run_claude_hook("release", subagent_input)
.expect("subagent release should keep parent pane working");
assert_eq!(release["method"], "pane.report_agent");
assert_eq!(release["params"]["state"], "working");
assert!(run_claude_hook("working", subagent_input).is_none());
assert!(run_claude_hook("idle", subagent_input).is_none());
assert!(run_claude_hook("release", subagent_input).is_none());
}
#[test]