fix: improve claude subagent hook lifecycle

fixes #58
This commit is contained in:
Ogulcan Celik 2026-04-27 21:13:14 +03:00
parent 19be0a6ca7
commit 0ef94cdbc1
4 changed files with 109 additions and 12 deletions

View File

@ -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:

View File

@ -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":

View File

@ -139,6 +139,27 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
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<ClaudeUninstallResult> {
"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());

View File

@ -349,13 +349,34 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value>
}
#[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]