use crate::event::{EventKind, HookEvent, ParseError, Wire};
use crate::output::Reply;
use serde_json::Value;
use std::io::{Read, Write};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
pub const DEFAULT_MODULE_DEADLINE: Duration = Duration::from_millis(3_000);
pub const DEFAULT_TOTAL_DEADLINE: Duration = Duration::from_millis(8_000);
pub const DEFAULT_CONTEXT_CAP_BYTES: usize = 60_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
BlockStop(String),
DenyTool(String),
AskTool(String),
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModuleOutput {
pub additional_context: Option<String>,
pub system_message: Option<String>,
pub decision: Option<Decision>,
pub detail: Value,
}
impl ModuleOutput {
pub fn empty() -> Self {
Self::default()
}
pub fn context(text: impl Into<String>) -> Self {
Self {
additional_context: Some(text.into()),
..Self::default()
}
}
pub fn decide(decision: Decision) -> Self {
Self {
decision: Some(decision),
..Self::default()
}
}
}
#[derive(Clone, Debug)]
pub struct ModuleContext {
pub deadline: Instant,
cancelled: Arc<AtomicBool>,
}
impl ModuleContext {
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
pub fn remaining(&self) -> Duration {
self.deadline.saturating_duration_since(Instant::now())
}
}
pub trait HookModule: Send + Sync {
fn id(&self) -> &str;
fn handle(&self, event: &HookEvent, ctx: &ModuleContext) -> Result<ModuleOutput, String>;
}
pub struct FnModule<F> {
id: String,
run: F,
}
impl<F> FnModule<F>
where
F: Fn(&HookEvent, &ModuleContext) -> Result<ModuleOutput, String> + Send + Sync,
{
pub fn new(id: impl Into<String>, run: F) -> Self {
Self { id: id.into(), run }
}
}
impl<F> HookModule for FnModule<F>
where
F: Fn(&HookEvent, &ModuleContext) -> Result<ModuleOutput, String> + Send + Sync,
{
fn id(&self) -> &str {
&self.id
}
fn handle(&self, event: &HookEvent, ctx: &ModuleContext) -> Result<ModuleOutput, String> {
(self.run)(event, ctx)
}
}
#[derive(Clone, Debug)]
pub struct DispatchOptions {
pub module_deadline: Duration,
pub total_deadline: Duration,
pub context_cap_bytes: usize,
pub fallback: Reply,
}
impl Default for DispatchOptions {
fn default() -> Self {
Self {
module_deadline: DEFAULT_MODULE_DEADLINE,
total_deadline: DEFAULT_TOTAL_DEADLINE,
context_cap_bytes: DEFAULT_CONTEXT_CAP_BYTES,
fallback: Reply::none(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Ok,
Error,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ModuleResult {
pub id: String,
pub status: Status,
pub output: Option<ModuleOutput>,
pub error: Option<String>,
pub elapsed: Duration,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DispatchResult {
pub event: EventKind,
pub status: Status,
pub results: Vec<ModuleResult>,
pub reply: Reply,
}
fn run_module(
module: &Arc<dyn HookModule>,
event: &Arc<HookEvent>,
budget: Duration,
) -> ModuleResult {
let started = Instant::now();
let id = module.id().to_owned();
let cancelled = Arc::new(AtomicBool::new(false));
let ctx = ModuleContext {
deadline: started + budget,
cancelled: cancelled.clone(),
};
let (tx, rx) = mpsc::channel();
let (module, event) = (module.clone(), event.clone());
let spawned = std::thread::Builder::new()
.name(format!("hook-module-{id}"))
.spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| module.handle(&event, &ctx)))
.unwrap_or_else(|_| Err("module panicked".to_owned()));
let _ = tx.send(result);
});
let outcome = match spawned {
Err(e) => Err(format!("module thread could not start: {e}")),
Ok(_) => match rx.recv_timeout(budget) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => {
cancelled.store(true, Ordering::Relaxed);
Err(format!("HookHost module timed out: {id}"))
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err("module ended without a result".into())
}
},
};
let elapsed = started.elapsed();
match outcome {
Ok(output) => ModuleResult {
id,
status: Status::Ok,
output: Some(output),
error: None,
elapsed,
},
Err(error) => ModuleResult {
id,
status: Status::Error,
output: None,
error: Some(error),
elapsed,
},
}
}
fn cap_text(mut text: String, cap: usize) -> String {
if text.len() > cap {
let mut cut = cap;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
}
text
}
fn join(parts: Vec<String>) -> Option<String> {
let parts: Vec<String> = parts.into_iter().filter(|p| !p.is_empty()).collect();
(!parts.is_empty()).then(|| parts.join("\n\n"))
}
pub fn compose_reply(event: &HookEvent, results: &[ModuleResult], context_cap: usize) -> Reply {
let outputs = results.iter().filter_map(|r| r.output.as_ref());
let decision = outputs.clone().find_map(|o| o.decision.clone());
let system = join(
outputs
.clone()
.filter_map(|o| o.system_message.clone())
.collect(),
);
let context = join(
outputs
.filter_map(|o| o.additional_context.clone())
.collect(),
)
.map(|c| cap_text(c, context_cap));
let base = match (&event.kind, decision) {
(EventKind::Stop, Some(Decision::BlockStop(reason))) => Some(Reply::block_stop(&reason)),
(EventKind::PreToolUse, Some(Decision::DenyTool(reason))) => {
Some(Reply::deny_tool(event.wire, &reason))
}
(EventKind::PreToolUse, Some(Decision::AskTool(reason))) => {
Some(Reply::ask_tool(event.wire, &reason))
}
_ => None,
};
let base = base.or_else(|| {
context
.as_deref()
.and_then(|c| Reply::additional_context(&event.kind, c).ok())
});
match (base, system) {
(Some(reply), Some(message)) => reply.with_system_message(&message),
(Some(reply), None) => reply,
(None, _) => Reply::none(),
}
}
pub fn dispatch(
event: HookEvent,
modules: &[Arc<dyn HookModule>],
options: &DispatchOptions,
) -> DispatchResult {
let started = Instant::now();
let event = Arc::new(event);
let mut results = Vec::with_capacity(modules.len());
for module in modules {
let left = options.total_deadline.saturating_sub(started.elapsed());
if left.is_zero() {
results.push(ModuleResult {
id: module.id().to_owned(),
status: Status::Error,
output: None,
error: Some("HookHost total deadline spent".into()),
elapsed: Duration::ZERO,
});
continue;
}
results.push(run_module(
module,
&event,
left.min(options.module_deadline),
));
}
let failed = results.iter().any(|r| r.status == Status::Error);
let mut reply = compose_reply(&event, &results, options.context_cap_bytes);
if failed && reply.is_none() {
reply = options.fallback.clone();
}
DispatchResult {
event: event.kind.clone(),
status: if failed { Status::Error } else { Status::Ok },
results,
reply,
}
}
pub fn run_with(
wire: Wire,
modules: &[Arc<dyn HookModule>],
options: &DispatchOptions,
input: impl Read,
output: impl Write,
errors: impl Write,
) -> i32 {
let reply = match HookEvent::from_reader(wire, input) {
Err(error) => fallback_for(&error, options),
Ok(event) => catch_unwind(AssertUnwindSafe(|| dispatch(event, modules, options).reply))
.unwrap_or_else(|_| options.fallback.clone()),
};
reply.write_to(output, errors)
}
fn fallback_for(_error: &ParseError, options: &DispatchOptions) -> Reply {
options.fallback.clone()
}
pub fn run(wire: Wire, modules: &[Arc<dyn HookModule>], options: &DispatchOptions) -> i32 {
run_with(
wire,
modules,
options,
std::io::stdin().lock(),
std::io::stdout().lock(),
std::io::stderr().lock(),
)
}