aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 20:42:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 20:42:14 +0300
commitf7b26a911772dab16a49d982002d2bbe58e0956b (patch)
tree78a24e14adb7cdd5e99b99c6688c84d416e8c5ef
parent05e44d003962cee6be4e289ac16038a72441ce7e (diff)
fix: stop claude subagent completion reviving working
refs #198
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh11
-rw-r--r--src/integration/mod.rs65
-rw-r--r--tests/cli_wrapper.rs14
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<ClaudeInstallPaths> {
"PostToolUseFailure",
&format!("bash {quoted_hook_path} working"),
)?;
+ remove_command_hook(
+ hooks,
+ "SubagentStop",
+ &format!("bash {quoted_hook_path} working"),
+ )?;
ensure_command_hook(
hooks,
"UserPromptSubmit",
@@ -614,13 +619,6 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
)?;
ensure_command_hook(
hooks,
- "SubagentStop",
- format!("bash {quoted_hook_path} working"),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
"Stop",
format!("bash {quoted_hook_path} idle"),
10,
@@ -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(),
),
@@ -1794,6 +1787,10 @@ mod tests {
"echo keep-failure"
);
assert_eq!(
+ settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"],
+ "echo keep-subagent"
+ );
+ assert_eq!(
settings["hooks"]["UserPromptSubmit"]
.as_array()
.unwrap()
@@ -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]