Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions src/hook_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,91 @@
use crate::cli::Commands;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookInput {
NoInput,
Stdin,
}

#[derive(Debug)]
pub(crate) enum HookAdmission {
NotHook,
Acquired(tracedecay::lifecycle_lease::LifecycleLease),
Busy,
}

pub(crate) fn admit_hook_command(command: &Commands) -> tracedecay::errors::Result<HookAdmission> {
if hook_input(command).is_none() {
return Ok(HookAdmission::NotHook);
}
Ok(admission_from_attempt(
tracedecay::lifecycle_lease::try_acquire_shared("agent hook"),
))
}

fn admission_from_attempt(
attempt: tracedecay::errors::Result<tracedecay::lifecycle_lease::SharedLeaseAttempt>,
) -> HookAdmission {
match attempt {
Ok(tracedecay::lifecycle_lease::SharedLeaseAttempt::Acquired(lease)) => {
HookAdmission::Acquired(lease)
}
Ok(tracedecay::lifecycle_lease::SharedLeaseAttempt::Busy) | Err(_) => HookAdmission::Busy,
}
}

pub(crate) fn drain_busy_hook_stdin(command: &Commands) {
use std::io::IsTerminal;

let Some(input_mode) = hook_input(command) else {
return;
};
if input_mode == HookInput::NoInput || std::io::stdin().is_terminal() {
return;
}
let _ = drain_hook_input(input_mode, &mut std::io::stdin().lock());
}

fn drain_hook_input(mode: HookInput, input: &mut impl std::io::Read) -> std::io::Result<u64> {
match mode {
HookInput::NoInput => Ok(0),
HookInput::Stdin => std::io::copy(input, &mut std::io::sink()),
}
}

pub(crate) fn hook_input(command: &Commands) -> Option<HookInput> {
match command {
Commands::HookPreToolUse | Commands::HookPromptSubmit | Commands::HookStop => {
Some(HookInput::NoInput)
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain Claude stdin hooks while quiescing

Claude's hooks reference says command hooks receive the event JSON on stdin, and this repo wires PreToolUse, UserPromptSubmit, and Stop as command hooks in plugin/hooks/hooks-claude.json. Classifying these variants as NoInput makes the new Busy path return without draining them; when an update/migration holds the exclusive lifecycle lease and Claude submits a large prompt/stop payload, Claude can see a broken pipe or hook failure instead of the intended fail-open quiescence. Please treat these as Stdin for the busy drain even if the dispatcher ignores the payload.

Useful? React with 👍 / 👎.

}
Commands::HookClaudeSessionStart
| Commands::HookClaudePostToolUse
| Commands::HookClaudeSubagentStart
| Commands::HookKiroPreToolUse
| Commands::HookKiroPromptSubmit
| Commands::HookKiroPostToolUse
| Commands::HookCursorSubagentStart
| Commands::HookCursorPostToolUse
| Commands::HookCursorBeforeSubmitPrompt
| Commands::HookCursorPreCompact
| Commands::HookCursorAfterFileEdit
| Commands::HookCursorSessionStart
| Commands::HookCursorSessionEnd
| Commands::HookCursorAfterShell
| Commands::HookCursorWorkspaceOpen
| Commands::HookCursorStop
| Commands::HookCodexSessionStart
| Commands::HookCodexUserPromptSubmit
| Commands::HookCodexSubagentStart
| Commands::HookCodexPostToolUse
| Commands::HookCodexPostCompact => Some(HookInput::Stdin),
_ => None,
}
}

pub(crate) async fn handle_hook_command(command: Commands) -> tracedecay::errors::Result<()> {
// Claude command hooks own a single JSON document on stdin. Early lease
// admission guarantees this dispatcher runs only while a shared lease is
// held; the busy path drains piped input without producing hook output.
match command {
Commands::HookPreToolUse => {
tracedecay::hooks::hook_pre_tool_use();
Expand Down Expand Up @@ -84,3 +169,142 @@ fn exit_if_nonzero(code: i32) {
std::process::exit(code);
}
}

#[cfg(test)]
mod tests {
use std::fs::OpenOptions;
use std::io::Write;

use super::{
Commands, HookAdmission, HookInput, admission_from_attempt, drain_hook_input, hook_input,
};
use tracedecay::lifecycle_lease::{
acquire_exclusive_for_profile, try_acquire_shared_for_profile,
};

fn hook_commands() -> Vec<(Commands, HookInput)> {
vec![
(Commands::HookPreToolUse, HookInput::NoInput),
(Commands::HookPromptSubmit, HookInput::NoInput),
(Commands::HookStop, HookInput::NoInput),
(Commands::HookClaudeSessionStart, HookInput::Stdin),
(Commands::HookClaudePostToolUse, HookInput::Stdin),
(Commands::HookClaudeSubagentStart, HookInput::Stdin),
(Commands::HookKiroPreToolUse, HookInput::Stdin),
(Commands::HookKiroPromptSubmit, HookInput::Stdin),
(Commands::HookKiroPostToolUse, HookInput::Stdin),
(Commands::HookCursorSubagentStart, HookInput::Stdin),
(Commands::HookCursorPostToolUse, HookInput::Stdin),
(Commands::HookCursorBeforeSubmitPrompt, HookInput::Stdin),
(Commands::HookCursorPreCompact, HookInput::Stdin),
(Commands::HookCursorAfterFileEdit, HookInput::Stdin),
(Commands::HookCursorSessionStart, HookInput::Stdin),
(Commands::HookCursorSessionEnd, HookInput::Stdin),
(Commands::HookCursorAfterShell, HookInput::Stdin),
(Commands::HookCursorWorkspaceOpen, HookInput::Stdin),
(Commands::HookCursorStop, HookInput::Stdin),
(Commands::HookCodexSessionStart, HookInput::Stdin),
(Commands::HookCodexUserPromptSubmit, HookInput::Stdin),
(Commands::HookCodexSubagentStart, HookInput::Stdin),
(Commands::HookCodexPostToolUse, HookInput::Stdin),
(Commands::HookCodexPostCompact, HookInput::Stdin),
]
}

#[test]
fn all_hook_commands_have_explicit_input_semantics() {
let hooks = hook_commands();
assert_eq!(hooks.len(), 24);
assert_eq!(
hooks
.iter()
.filter(|(_, input)| *input == HookInput::NoInput)
.count(),
3
);
assert_eq!(
hooks
.iter()
.filter(|(_, input)| *input == HookInput::Stdin)
.count(),
21
);
for (command, expected) in hooks {
assert_eq!(hook_input(&command), Some(expected));
assert!(crate::should_skip_agent_install_maintenance(&command));
}
}

#[test]
fn unrelated_exclusive_owner_produces_busy_admission() {
let tmp = tempfile::tempdir().unwrap();
let lock_path = tmp.path().join("lifecycle.lock");
let mut external = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.unwrap();
fs2::FileExt::try_lock_exclusive(&external).unwrap();
writeln!(external, "external-token\tmigration\t999").unwrap();
external.flush().unwrap();
let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook");

assert!(matches!(
admission_from_attempt(attempt),
HookAdmission::Busy
));
}

#[test]
fn process_owned_exclusive_lease_is_not_inherited_by_hooks() {
let tmp = tempfile::tempdir().unwrap();
let _exclusive = acquire_exclusive_for_profile(tmp.path(), "post-update").unwrap();
let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook");

assert!(matches!(
admission_from_attempt(attempt),
HookAdmission::Busy
));
}

#[test]
fn normal_shared_lease_admits_hook_dispatch() {
let tmp = tempfile::tempdir().unwrap();
let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook");

assert!(matches!(
admission_from_attempt(attempt),
HookAdmission::Acquired(_)
));
}

#[test]
fn lifecycle_profile_errors_quiesce_hooks_like_a_busy_lease() {
let tmp = tempfile::tempdir().unwrap();
let profile_file = tmp.path().join("not-a-profile");
std::fs::write(&profile_file, "file").unwrap();
let attempt = try_acquire_shared_for_profile(&profile_file, "agent hook");

assert!(matches!(
admission_from_attempt(attempt),
HookAdmission::Busy
));
}

#[test]
fn busy_stdin_hooks_drain_but_legacy_no_input_hooks_do_not() {
let mut stdin_payload = b"{\"hook_event_name\":\"SessionStart\"}".as_slice();
let stdin_len = stdin_payload.len() as u64;
let drained = drain_hook_input(HookInput::Stdin, &mut stdin_payload).unwrap();
assert_eq!(drained, stdin_len);
assert!(stdin_payload.is_empty());

let mut legacy_payload = b"terminal input must remain unread".as_slice();
let legacy_len = legacy_payload.len();
let drained = drain_hook_input(HookInput::NoInput, &mut legacy_payload).unwrap();
assert_eq!(drained, 0);
assert_eq!(legacy_payload.len(), legacy_len);
}
}
Loading
Loading