1use std::path::PathBuf;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde_json::Value;
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::process::Command;
10use tokio::sync::Mutex;
11use tokio::time;
12
13use crate::config::{HookCommandConfig, HooksConfig};
14use crate::exec::events::{CompactionMode, CompactionTrigger};
15use crate::permissions::PermissionRequest;
16
17use crate::hooks::lifecycle::compiled::CompiledLifecycleHooks;
18use crate::hooks::lifecycle::interpret::{
19 HookCommandResult, interpret_permission_request, interpret_post_tool, interpret_pre_tool,
20 interpret_session_end, interpret_session_start, interpret_stop, interpret_user_prompt,
21};
22use crate::hooks::lifecycle::types::{
23 HookMessage, NotificationHookType, PermissionRequestHookOutcome, PostToolHookOutcome,
24 PreCompactHookOutcome, PreToolHookDecision, PreToolHookOutcome, SessionEndReason,
25 SessionStartHookOutcome, SessionStartTrigger, StopHookOutcome, UserPromptHookOutcome,
26};
27use crate::hooks::lifecycle::utils::{generate_session_id, path_to_string};
28
29const DEFAULT_TIMEOUT_SECS: u64 = 60;
30
31mod payloads;
32
33#[derive(Clone)]
34pub struct LifecycleHookEngine {
35 inner: Arc<LifecycleHookInner>,
36}
37
38impl LifecycleHookEngine {
39 pub fn new(
40 workspace: PathBuf,
41 config: &HooksConfig,
42 trigger: SessionStartTrigger,
43 ) -> Result<Option<Self>> {
44 Self::new_with_session(workspace, config, trigger, generate_session_id())
45 }
46
47 pub fn new_with_session(
48 workspace: PathBuf,
49 config: &HooksConfig,
50 trigger: SessionStartTrigger,
51 session_id: impl Into<String>,
52 ) -> Result<Option<Self>> {
53 if config.lifecycle.is_empty() {
54 return Ok(None);
55 }
56
57 let compiled = CompiledLifecycleHooks::from_config(&config.lifecycle)?;
58 if compiled.is_empty() {
59 return Ok(None);
60 }
61
62 Ok(Some(Self {
63 inner: Arc::new(LifecycleHookInner {
64 workspace,
65 session_id: session_id.into(),
66 trigger,
67 hooks: compiled,
68 state: Mutex::new(LifecycleHookState {
69 transcript_path: None,
70 }),
71 }),
72 }))
73 }
74
75 pub async fn run_session_start(&self) -> Result<SessionStartHookOutcome> {
76 let mut messages = Vec::new();
77 let mut additional_context = Vec::new();
78
79 if self.inner.hooks.session_start.is_empty() {
80 return Ok(SessionStartHookOutcome {
81 messages,
82 additional_context,
83 });
84 }
85
86 let trigger_value = self.inner.trigger.as_str().to_owned();
87 let payload = self.build_session_start_payload().await?;
88
89 for group in &self.inner.hooks.session_start {
90 if !group.matcher.matches(&trigger_value) {
91 continue;
92 }
93
94 for command in &group.commands {
95 match self
96 .execute_command("SessionStart", command, &payload)
97 .await
98 {
99 Ok(result) => interpret_session_start(
100 command,
101 &result,
102 &mut messages,
103 &mut additional_context,
104 self.inner.hooks.quiet_success_output,
105 ),
106 Err(err) => messages.push(HookMessage::error(format!(
107 "SessionStart hook `{}` failed: {err}",
108 command.command
109 ))),
110 }
111 }
112 }
113
114 Ok(SessionStartHookOutcome {
115 messages,
116 additional_context,
117 })
118 }
119
120 pub async fn run_session_end(
121 &self,
122 turn_id: &str,
123 reason: SessionEndReason,
124 ) -> Result<Vec<HookMessage>> {
125 let mut messages = Vec::new();
126
127 if self.inner.hooks.session_end.is_empty() {
128 return Ok(messages);
129 }
130
131 let payload = self.build_session_end_payload(turn_id, reason).await?;
132 let reason_value = reason.as_str().to_owned();
133
134 for group in &self.inner.hooks.session_end {
135 if !group.matcher.matches(&reason_value) {
136 continue;
137 }
138
139 for command in &group.commands {
140 match self.execute_command("SessionEnd", command, &payload).await {
141 Ok(result) => interpret_session_end(
142 command,
143 &result,
144 &mut messages,
145 self.inner.hooks.quiet_success_output,
146 ),
147 Err(err) => messages.push(HookMessage::error(format!(
148 "SessionEnd hook `{}` failed: {err}",
149 command.command
150 ))),
151 }
152 }
153 }
154
155 Ok(messages)
156 }
157
158 #[expect(clippy::too_many_arguments)]
159 pub async fn run_subagent_start(
160 &self,
161 parent_session_id: &str,
162 child_thread_id: &str,
163 agent_name: &str,
164 display_label: &str,
165 background: bool,
166 status: &str,
167 transcript_path: Option<&std::path::Path>,
168 ) -> Result<Vec<HookMessage>> {
169 let mut messages = Vec::new();
170
171 if self.inner.hooks.subagent_start.is_empty() {
172 return Ok(messages);
173 }
174
175 let payload = self
176 .build_subagent_start_payload(
177 parent_session_id,
178 child_thread_id,
179 agent_name,
180 display_label,
181 background,
182 status,
183 transcript_path,
184 )
185 .await?;
186 let matcher_value = agent_name.to_owned();
187
188 for group in &self.inner.hooks.subagent_start {
189 if !group.matcher.matches(&matcher_value) {
190 continue;
191 }
192
193 for command in &group.commands {
194 match self
195 .execute_command("SubagentStart", command, &payload)
196 .await
197 {
198 Ok(result) => interpret_session_end(
199 command,
200 &result,
201 &mut messages,
202 self.inner.hooks.quiet_success_output,
203 ),
204 Err(err) => messages.push(HookMessage::error(format!(
205 "SubagentStart hook `{}` failed: {err}",
206 command.command
207 ))),
208 }
209 }
210 }
211
212 Ok(messages)
213 }
214
215 #[expect(clippy::too_many_arguments)]
216 pub async fn run_subagent_stop(
217 &self,
218 parent_session_id: &str,
219 child_thread_id: &str,
220 agent_name: &str,
221 display_label: &str,
222 background: bool,
223 status: &str,
224 transcript_path: Option<&std::path::Path>,
225 ) -> Result<Vec<HookMessage>> {
226 let mut messages = Vec::new();
227
228 if self.inner.hooks.subagent_stop.is_empty() {
229 return Ok(messages);
230 }
231
232 let payload = self
233 .build_subagent_stop_payload(
234 parent_session_id,
235 child_thread_id,
236 agent_name,
237 display_label,
238 background,
239 status,
240 transcript_path,
241 )
242 .await?;
243 let matcher_value = agent_name.to_owned();
244
245 for group in &self.inner.hooks.subagent_stop {
246 if !group.matcher.matches(&matcher_value) {
247 continue;
248 }
249
250 for command in &group.commands {
251 match self
252 .execute_command("SubagentStop", command, &payload)
253 .await
254 {
255 Ok(result) => interpret_session_end(
256 command,
257 &result,
258 &mut messages,
259 self.inner.hooks.quiet_success_output,
260 ),
261 Err(err) => messages.push(HookMessage::error(format!(
262 "SubagentStop hook `{}` failed: {err}",
263 command.command
264 ))),
265 }
266 }
267 }
268
269 Ok(messages)
270 }
271
272 pub async fn run_user_prompt_submit(
273 &self,
274 turn_id: &str,
275 prompt: &str,
276 ) -> Result<UserPromptHookOutcome> {
277 let mut outcome = UserPromptHookOutcome::default();
278
279 if self.inner.hooks.user_prompt_submit.is_empty() {
280 return Ok(outcome);
281 }
282
283 let payload = self.build_user_prompt_payload(turn_id, prompt).await?;
284
285 for group in &self.inner.hooks.user_prompt_submit {
286 if !group.matcher.matches(prompt) {
287 continue;
288 }
289
290 for command in &group.commands {
291 match self
292 .execute_command("UserPromptSubmit", command, &payload)
293 .await
294 {
295 Ok(result) => {
296 interpret_user_prompt(
297 command,
298 &result,
299 &mut outcome,
300 self.inner.hooks.quiet_success_output,
301 );
302 if !outcome.allow_prompt {
303 return Ok(outcome);
304 }
305 }
306 Err(err) => outcome.messages.push(HookMessage::error(format!(
307 "UserPromptSubmit hook `{}` failed: {err}",
308 command.command
309 ))),
310 }
311 }
312 }
313
314 Ok(outcome)
315 }
316
317 pub async fn run_permission_request(
318 &self,
319 tool_name: &str,
320 tool_input: Option<&Value>,
321 permission_request: &PermissionRequest,
322 permission_suggestions: &[Value],
323 ) -> Result<PermissionRequestHookOutcome> {
324 let mut outcome = PermissionRequestHookOutcome::default();
325
326 if self.inner.hooks.permission_request.is_empty() {
327 return Ok(outcome);
328 }
329
330 let payload = self
331 .build_permission_request_payload(
332 tool_name,
333 tool_input,
334 permission_request,
335 permission_suggestions,
336 )
337 .await?;
338
339 for group in &self.inner.hooks.permission_request {
340 if !group.matcher.matches(tool_name) {
341 continue;
342 }
343
344 for command in &group.commands {
345 match self
346 .execute_command("PermissionRequest", command, &payload)
347 .await
348 {
349 Ok(result) => {
350 interpret_permission_request(
351 command,
352 &result,
353 &mut outcome,
354 self.inner.hooks.quiet_success_output,
355 );
356 if outcome.decision.is_some() {
357 return Ok(outcome);
358 }
359 }
360 Err(err) => outcome.messages.push(HookMessage::error(format!(
361 "PermissionRequest hook `{}` failed: {err}",
362 command.command
363 ))),
364 }
365 }
366 }
367
368 Ok(outcome)
369 }
370
371 pub async fn run_pre_tool_use(
372 &self,
373 tool_name: &str,
374 tool_input: Option<&Value>,
375 tool_call_id: Option<&str>,
376 ) -> Result<PreToolHookOutcome> {
377 let mut outcome = PreToolHookOutcome::default();
378
379 if self.inner.hooks.pre_tool_use.is_empty() {
380 return Ok(outcome);
381 }
382
383 let payload = self
384 .build_pre_tool_payload(tool_name, tool_input, tool_call_id)
385 .await?;
386
387 for group in &self.inner.hooks.pre_tool_use {
388 if !group.matcher.matches(tool_name) {
389 continue;
390 }
391
392 for command in &group.commands {
393 match self.execute_command("PreToolUse", command, &payload).await {
394 Ok(result) => {
395 interpret_pre_tool(
396 command,
397 &result,
398 &mut outcome,
399 self.inner.hooks.quiet_success_output,
400 );
401 match outcome.decision {
402 PreToolHookDecision::Allow | PreToolHookDecision::Deny => {
403 return Ok(outcome);
404 }
405 _ => {}
406 }
407 }
408 Err(err) => outcome.messages.push(HookMessage::error(format!(
409 "PreToolUse hook `{}` failed: {err}",
410 command.command
411 ))),
412 }
413 }
414 }
415
416 Ok(outcome)
417 }
418
419 pub async fn run_post_tool_use(
420 &self,
421 tool_name: &str,
422 tool_input: Option<&Value>,
423 tool_output: &Value,
424 tool_call_id: Option<&str>,
425 ) -> Result<PostToolHookOutcome> {
426 let mut outcome = PostToolHookOutcome::default();
427
428 if self.inner.hooks.post_tool_use.is_empty() {
429 return Ok(outcome);
430 }
431
432 let payload = self
433 .build_post_tool_payload(tool_name, tool_input, tool_output, tool_call_id)
434 .await?;
435
436 for group in &self.inner.hooks.post_tool_use {
437 if !group.matcher.matches(tool_name) {
438 continue;
439 }
440
441 for command in &group.commands {
442 match self.execute_command("PostToolUse", command, &payload).await {
443 Ok(result) => interpret_post_tool(
444 command,
445 &result,
446 &mut outcome,
447 self.inner.hooks.quiet_success_output,
448 ),
449 Err(err) => outcome.messages.push(HookMessage::error(format!(
450 "PostToolUse hook `{}` failed: {err}",
451 command.command
452 ))),
453 }
454 }
455 }
456
457 Ok(outcome)
458 }
459
460 pub async fn run_pre_compact(
461 &self,
462 trigger: CompactionTrigger,
463 mode: CompactionMode,
464 original_message_count: usize,
465 compacted_message_count: usize,
466 history_artifact_path: Option<&str>,
467 ) -> Result<PreCompactHookOutcome> {
468 let mut outcome = PreCompactHookOutcome::default();
469
470 if self.inner.hooks.pre_compact.is_empty() {
471 return Ok(outcome);
472 }
473
474 let payload = self
475 .build_pre_compact_payload(
476 trigger,
477 mode,
478 original_message_count,
479 compacted_message_count,
480 history_artifact_path,
481 )
482 .await?;
483 let trigger_value = trigger.as_str().to_owned();
484
485 for group in &self.inner.hooks.pre_compact {
486 if !group.matcher.matches(&trigger_value) {
487 continue;
488 }
489
490 for command in &group.commands {
491 match self.execute_command("PreCompact", command, &payload).await {
492 Ok(result) => {
493 interpret_session_end(
494 command,
495 &result,
496 &mut outcome.messages,
497 self.inner.hooks.quiet_success_output,
498 );
499 }
500 Err(err) => outcome.messages.push(HookMessage::error(format!(
501 "PreCompact hook `{}` failed: {err}",
502 command.command
503 ))),
504 }
505 }
506 }
507
508 Ok(outcome)
509 }
510
511 pub async fn run_notification(
512 &self,
513 notification_type: NotificationHookType,
514 title: &str,
515 message: &str,
516 ) -> Result<Vec<HookMessage>> {
517 let mut messages = Vec::new();
518
519 if self.inner.hooks.notification.is_empty() {
520 return Ok(messages);
521 }
522
523 let payload = self
524 .build_notification_payload(notification_type, title, message)
525 .await?;
526 let matcher_value = notification_type.as_str().to_owned();
527
528 for group in &self.inner.hooks.notification {
529 if !group.matcher.matches(&matcher_value) {
530 continue;
531 }
532
533 for command in &group.commands {
534 match self
535 .execute_command("Notification", command, &payload)
536 .await
537 {
538 Ok(result) => interpret_session_end(
539 command,
540 &result,
541 &mut messages,
542 self.inner.hooks.quiet_success_output,
543 ),
544 Err(err) => messages.push(HookMessage::error(format!(
545 "Notification hook `{}` failed: {err}",
546 command.command
547 ))),
548 }
549 }
550 }
551
552 Ok(messages)
553 }
554
555 pub async fn run_stop(
556 &self,
557 last_assistant_message: &str,
558 stop_hook_active: bool,
559 ) -> Result<StopHookOutcome> {
560 let mut outcome = StopHookOutcome::default();
561
562 if self.inner.hooks.stop.is_empty() {
563 return Ok(outcome);
564 }
565
566 let payload = self
567 .build_stop_payload(last_assistant_message, stop_hook_active)
568 .await?;
569
570 for group in &self.inner.hooks.stop {
571 if !group.matcher.matches("stop") {
572 continue;
573 }
574
575 for command in &group.commands {
576 match self.execute_command("Stop", command, &payload).await {
577 Ok(result) => {
578 interpret_stop(
579 command,
580 &result,
581 &mut outcome,
582 self.inner.hooks.quiet_success_output,
583 );
584 if outcome.block_reason.is_some() {
585 return Ok(outcome);
586 }
587 }
588 Err(err) => outcome.messages.push(HookMessage::error(format!(
589 "Stop hook `{}` failed: {err}",
590 command.command
591 ))),
592 }
593 }
594 }
595
596 Ok(outcome)
597 }
598
599 pub async fn update_transcript_path(&self, path: Option<PathBuf>) {
600 let mut state = self.inner.state.lock().await;
601 state.transcript_path = path;
602 }
603
604 pub async fn transcript_path(&self) -> Option<PathBuf> {
605 let state = self.inner.state.lock().await;
606 state.transcript_path.clone()
607 }
608
609 async fn execute_command(
610 &self,
611 event_name: &str,
612 command: &HookCommandConfig,
613 payload: &Value,
614 ) -> Result<HookCommandResult> {
615 let mut process = Command::new("sh");
616 process.arg("-c").arg(&command.command);
617 process.current_dir(&self.inner.workspace);
618 process.stdin(Stdio::piped());
619 process.stdout(Stdio::piped());
620 process.stderr(Stdio::piped());
621 process.kill_on_drop(true);
622
623 let workspace_str = self.inner.workspace.to_string_lossy().into_owned();
624 process.env("VT_PROJECT_DIR", &workspace_str);
625 process.env("CLAUDE_PROJECT_DIR", &workspace_str);
626 process.env("VT_SESSION_ID", &self.inner.session_id);
627 process.env("CLAUDE_SESSION_ID", &self.inner.session_id);
628 process.env("VT_HOOK_EVENT", event_name);
629
630 if let Some(transcript_path) = self.current_transcript_path().await {
631 process.env("VT_TRANSCRIPT_PATH", &transcript_path);
632 process.env("CLAUDE_TRANSCRIPT_PATH", &transcript_path);
633 }
634
635 let mut child = process
636 .spawn()
637 .with_context(|| format!("failed to spawn lifecycle hook `{}`", command.command))?;
638
639 if let Some(mut stdin) = child.stdin.take() {
640 let mut payload_bytes = serde_json::to_vec(payload)
641 .context("failed to serialize lifecycle hook payload")?;
642 payload_bytes.push(b'\n');
643 stdin
644 .write_all(&payload_bytes)
645 .await
646 .context("failed to write lifecycle hook payload")?;
647 stdin
648 .shutdown()
649 .await
650 .context("failed to close lifecycle hook stdin")?;
651 }
652
653 let mut stdout_pipe = child
654 .stdout
655 .take()
656 .context("lifecycle hook missing stdout pipe")?;
657 let mut stderr_pipe = child
658 .stderr
659 .take()
660 .context("lifecycle hook missing stderr pipe")?;
661
662 let stdout_task = tokio::spawn(async move {
663 let mut buffer = Vec::new();
664 stdout_pipe.read_to_end(&mut buffer).await.map(|_| buffer)
665 });
666 let stderr_task = tokio::spawn(async move {
667 let mut buffer = Vec::new();
668 stderr_pipe.read_to_end(&mut buffer).await.map(|_| buffer)
669 });
670
671 let timeout_secs = command
672 .timeout_seconds
673 .unwrap_or(DEFAULT_TIMEOUT_SECS)
674 .max(1);
675 let wait_result = time::timeout(Duration::from_secs(timeout_secs), child.wait()).await;
676
677 let (exit_code, timed_out) = match wait_result {
678 Ok(status_res) => {
679 let status = status_res.context("failed to wait for lifecycle hook")?;
680 (status.code(), false)
681 }
682 Err(_) => {
683 let _ = child.start_kill();
684 let _ = child.wait().await;
685 (None, true)
686 }
687 };
688
689 let stdout_bytes = stdout_task
690 .await
691 .unwrap_or_else(|_| Ok(Vec::new()))
692 .unwrap_or_default();
693 let stderr_bytes = stderr_task
694 .await
695 .unwrap_or_else(|_| Ok(Vec::new()))
696 .unwrap_or_default();
697
698 Ok(HookCommandResult {
699 exit_code,
700 stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
701 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
702 timed_out,
703 timeout_seconds: timeout_secs,
704 })
705 }
706
707 async fn current_transcript_path(&self) -> Option<String> {
708 let state = self.inner.state.lock().await;
709 state
710 .transcript_path
711 .as_ref()
712 .and_then(|path| path_to_string(path))
713 }
714}
715
716struct LifecycleHookInner {
717 workspace: PathBuf,
718 session_id: String,
719 trigger: SessionStartTrigger,
720 hooks: CompiledLifecycleHooks,
721 state: Mutex<LifecycleHookState>,
722}
723
724struct LifecycleHookState {
725 transcript_path: Option<PathBuf>,
726}