use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
use tmai_core::api::{CoreEvent, TmaiCore};
use tmai_core::auto_approve::types::PermissionDecision;
use tmai_core::hooks::handler::{handle_hook_event, handle_statusline, resolve_pane_id};
use tmai_core::hooks::{HookEventPayload, StatuslineData};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HookEventResponse {
#[serde(rename = "continue")]
should_continue: bool,
#[serde(skip_serializing_if = "Option::is_none")]
stop_reason: Option<String>,
}
pub async fn hook_event(
State(core): State<Arc<TmaiCore>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let payload: HookEventPayload = match serde_json::from_slice(&body) {
Ok(p) => p,
Err(e) => {
debug!("Hook event rejected: invalid JSON payload: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid JSON"})),
);
}
};
let token_valid = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| core.validate_hook_token(token))
.unwrap_or(false);
if !token_valid {
debug!("Hook event rejected: invalid or missing token");
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})));
}
let pre_tool_use_response = if payload.hook_event_name == "PreToolUse" {
core.evaluate_pre_tool_use(&payload)
} else {
None
};
let header_pane_id = headers.get("x-tmai-pane-id").and_then(|v| v.to_str().ok());
#[allow(deprecated)]
let pane_id = match resolve_pane_id(
header_pane_id,
&payload.session_id,
payload.cwd.as_deref(),
core.session_pane_map(),
core.raw_state(),
) {
Some(id) => id,
None => {
warn!(
event = %payload.hook_event_name,
session_id = %payload.session_id,
"Could not resolve pane_id for hook event"
);
return (StatusCode::OK, Json(serde_json::json!({})));
}
};
let event_name = payload.hook_event_name.clone();
let core_event = handle_hook_event(
&payload,
&pane_id,
core.hook_registry(),
core.session_pane_map(),
);
if let Some(event) = core_event {
let _ = core.event_sender().send(event);
}
if event_name == "PermissionDenied" {
core.audit_helper().emit_permission_denied(
&pane_id,
"ClaudeCode",
payload.tool_name.clone(),
payload.tool_input.clone(),
payload.permission_mode.clone(),
);
}
core.notify_agents_updated();
match event_name.as_str() {
"PreToolUse" => {
if let Some(decision) = pre_tool_use_response {
match decision.decision {
PermissionDecision::Defer => {
let tool_name = payload
.tool_name
.clone()
.unwrap_or_else(|| "unknown".into());
let (defer_id, rx) = core.defer_registry().defer(
payload.session_id.clone(),
pane_id.clone(),
tool_name.clone(),
payload.tool_input.clone(),
payload.cwd.clone(),
);
info!(
defer_id,
pane_id = %pane_id,
tool = %tool_name,
"Tool call deferred, awaiting resolution"
);
let _ = core.event_sender().send(CoreEvent::ToolCallDeferred {
defer_id,
target: pane_id.clone(),
tool_name: tool_name.clone(),
});
let defer_timeout =
Duration::from_secs(core.settings().auto_approve.timeout_secs);
let resolution = tokio::time::timeout(defer_timeout, rx).await;
match resolution {
Ok(Ok(res)) => {
let final_decision = res.decision.as_str();
info!(
defer_id,
decision = final_decision,
resolved_by = %res.resolved_by,
"Deferred tool call resolved"
);
let _ = core.event_sender().send(CoreEvent::ToolCallResolved {
defer_id,
target: pane_id,
decision: final_decision.to_string(),
resolved_by: res.resolved_by.clone(),
});
let response = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": final_decision,
"permissionDecisionReason": res.reason
}
});
(StatusCode::OK, Json(response))
}
_ => {
warn!(
defer_id,
"Deferred tool call timed out, falling back to ask"
);
core.defer_registry().remove(defer_id);
let _ = core.event_sender().send(CoreEvent::ToolCallResolved {
defer_id,
target: pane_id,
decision: "ask".into(),
resolved_by: "timeout".into(),
});
let response = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Defer timed out"
}
});
(StatusCode::OK, Json(response))
}
}
}
_ => {
let response = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision.decision.as_str(),
"permissionDecisionReason": decision.reason
}
});
info!(
pane_id = %pane_id,
tool = ?payload.tool_name,
decision = decision.decision.as_str(),
model = %decision.model,
elapsed_ms = decision.elapsed_ms,
"PreToolUse auto-approve"
);
(StatusCode::OK, Json(response))
}
}
} else {
(StatusCode::OK, Json(serde_json::json!({})))
}
}
"TeammateIdle" | "TaskCreated" | "TaskCompleted" => {
let response = HookEventResponse {
should_continue: true,
stop_reason: None,
};
(
StatusCode::OK,
Json(serde_json::to_value(response).unwrap_or(serde_json::Value::Null)),
)
}
_ => (StatusCode::OK, Json(serde_json::json!({}))),
}
}
pub async fn statusline(
State(core): State<Arc<TmaiCore>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let data: StatuslineData = match serde_json::from_slice(&body) {
Ok(d) => d,
Err(e) => {
debug!("Statusline rejected: invalid JSON payload: {}", e);
return StatusCode::BAD_REQUEST;
}
};
let token_valid = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| core.validate_hook_token(token))
.unwrap_or(false);
if !token_valid {
debug!("Statusline rejected: invalid or missing token");
return StatusCode::UNAUTHORIZED;
}
let header_pane_id = headers.get("x-tmai-pane-id").and_then(|v| v.to_str().ok());
let session_id = data.session_id.as_deref().unwrap_or("");
let cwd = data.cwd.as_deref();
#[allow(deprecated)]
let pane_id = match resolve_pane_id(
header_pane_id,
session_id,
cwd,
core.session_pane_map(),
core.raw_state(),
) {
Some(id) => id,
None => {
warn!(
session_id = %session_id,
"Could not resolve pane_id for statusline data"
);
return StatusCode::OK;
}
};
handle_statusline(
data,
&pane_id,
core.hook_registry(),
core.session_pane_map(),
);
core.notify_agents_updated();
StatusCode::OK
}
#[derive(Debug, Deserialize)]
pub struct ReviewCompletePayload {
pub source_target: String,
pub summary: String,
}
pub async fn review_complete(
State(core): State<Arc<TmaiCore>>,
headers: HeaderMap,
Json(payload): Json<ReviewCompletePayload>,
) -> impl IntoResponse {
let token_valid = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| core.validate_hook_token(token))
.unwrap_or(false);
if !token_valid {
debug!("Review complete rejected: invalid or missing token");
return StatusCode::UNAUTHORIZED;
}
info!(
source_target = %payload.source_target,
summary = %payload.summary,
"Review completed"
);
let _ = core.event_sender().send(CoreEvent::ReviewCompleted {
source_target: payload.source_target,
summary: payload.summary,
});
StatusCode::OK
}