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};
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}