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