Skip to main content

vtcode_core/hooks/lifecycle/
engine.rs

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}