diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md index b25c0ab8..2489f619 100644 --- a/INTEGRATIONS.md +++ b/INTEGRATIONS.md @@ -91,14 +91,17 @@ current hook mapping: - `UserPromptSubmit` → `working` - `PreToolUse` → `working` - `PermissionRequest` → `blocked` +- `PostToolUse` → `working` +- `PostToolUseFailure` → `working` +- `SubagentStop` → `working` - `Stop` → `idle` - `SessionEnd` → `release` notes: -- claude's current hook surface improves state reporting, but it is not a perfect permission lifecycle. -- when a permission prompt is canceled, claude does not currently give herdr a clean hook event that always resolves the pane out of `blocked` immediately. -- that is acceptable in herdr's model: process detection still owns liveness, and heuristics remain the fallback for unresolved edges. +- claude code hooks also run inside subagents. herdr treats subagent `working` and `blocked` reports as real pane state. +- subagent stop/release events are converted to `working` by the bundled hook script so a completed subagent does not make the parent claude pane look idle. +- `PostToolUse` and `PostToolUseFailure` move the pane back to `working` after a permissioned tool call resolves. uninstall: diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index d8acdd76..527fe9f8 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -46,8 +46,8 @@ if hook_input_file: hook_input = {} is_subagent = bool(hook_input.get("agent_id")) -if is_subagent: - raise SystemExit(0) +if is_subagent and action in ("idle", "release"): + action = "working" request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" if action == "release": diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 5240420a..5d478d0e 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -139,6 +139,27 @@ pub(crate) fn install_claude() -> io::Result { 10, Some("*"), )?; + ensure_command_hook( + hooks, + "PostToolUse", + format!("bash {quoted_hook_path} working"), + 10, + Some("*"), + )?; + ensure_command_hook( + hooks, + "PostToolUseFailure", + format!("bash {quoted_hook_path} working"), + 10, + Some("*"), + )?; + ensure_command_hook( + hooks, + "SubagentStop", + format!("bash {quoted_hook_path} working"), + 10, + Some("*"), + )?; ensure_command_hook( hooks, "Stop", @@ -304,6 +325,21 @@ pub(crate) fn uninstall_claude() -> io::Result { "PermissionRequest", &format!("bash {quoted_hook_path} blocked"), )?; + updated_settings |= remove_command_hook( + hooks, + "PostToolUse", + &format!("bash {quoted_hook_path} working"), + )?; + updated_settings |= remove_command_hook( + hooks, + "PostToolUseFailure", + &format!("bash {quoted_hook_path} working"), + )?; + updated_settings |= remove_command_hook( + hooks, + "SubagentStop", + &format!("bash {quoted_hook_path} working"), + )?; updated_settings |= remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; updated_settings |= remove_command_hook( @@ -747,6 +783,20 @@ mod tests { .unwrap() .contains(" blocked") ); + assert!(settings["hooks"]["PostToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(" working")); + assert!( + settings["hooks"]["PostToolUseFailure"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(" working") + ); + assert!(settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(" working")); assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"] .as_str() .unwrap() @@ -790,6 +840,21 @@ mod tests { .len(), 1 ); + assert_eq!( + settings["hooks"]["PostToolUse"].as_array().unwrap().len(), + 1 + ); + assert_eq!( + settings["hooks"]["PostToolUseFailure"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + settings["hooks"]["SubagentStop"].as_array().unwrap().len(), + 1 + ); assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1); @@ -810,7 +875,11 @@ mod tests { fs::write( claude_dir.join("settings.json"), format!( - r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#, + r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#, + hook_path.display(), + hook_path.display(), + hook_path.display(), + hook_path.display(), hook_path.display(), hook_path.display(), hook_path.display(), @@ -838,6 +907,10 @@ mod tests { settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], "echo keep" ); + assert!(settings["hooks"].get("PermissionRequest").is_none()); + assert!(settings["hooks"].get("PostToolUse").is_none()); + assert!(settings["hooks"].get("PostToolUseFailure").is_none()); + assert!(settings["hooks"].get("SubagentStop").is_none()); assert!(settings["hooks"].get("Stop").is_none()); assert!(settings["hooks"].get("SessionEnd").is_none()); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index f3b02b9b..46f199b1 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -349,13 +349,34 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option } #[test] -fn claude_hook_suppresses_subagent_reports() { - let subagent_input = r#"{"hook_event_name":"PermissionRequest","agent_id":"agent-abc123","agent_type":"Explore"}"#; +fn claude_hook_reports_subagent_working_and_blocked() { + let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; - assert!(run_claude_hook("working", subagent_input).is_none()); - assert!(run_claude_hook("blocked", subagent_input).is_none()); - assert!(run_claude_hook("idle", subagent_input).is_none()); - assert!(run_claude_hook("release", subagent_input).is_none()); + let working = + run_claude_hook("working", subagent_input).expect("subagent working should report working"); + assert_eq!(working["method"], "pane.report_agent"); + assert_eq!(working["params"]["state"], "working"); + + let blocked = + run_claude_hook("blocked", subagent_input).expect("subagent blocked should report blocked"); + assert_eq!(blocked["method"], "pane.report_agent"); + assert_eq!(blocked["params"]["state"], "blocked"); +} + +#[test] +fn claude_hook_converts_subagent_idle_and_release_to_working() { + 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"); } #[test]