From f7b26a911772dab16a49d982002d2bbe58e0956b Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 20 May 2026 20:42:14 +0300 Subject: [PATCH] fix: stop claude subagent completion reviving working refs #198 --- docs/next/CHANGELOG.md | 1 + .../assets/claude/herdr-agent-state.sh | 11 +++- src/integration/mod.rs | 65 +++++++++++++------ tests/cli_wrapper.rs | 14 ++-- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 6d7094a6..63b2ee07 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -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 diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 035c3fe0..5042cad8 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -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() diff --git a/src/integration/mod.rs b/src/integration/mod.rs index ae3756b0..8b6b854c 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -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 { "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 { 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"); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index de1f1457..4d06e618 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -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]