magi-code 0.63.4

Repository-aware CLI coding agent for terminal work
Documentation
use super::activity::hook_activity_id;
use crate::{
    config::{HookDefinition, HookFailurePolicy},
    output::{ToolDispatchContext, redact_sensitive_text},
    providers::{ProviderConversationItem, ToolCall},
};
use serde_json::{Value, json};
use std::{error::Error, fmt};

const HOOK_LIFECYCLE_MESSAGE_MAX_CHARS: usize = 1024;
const HOOK_PROVIDER_CONTEXT_SCHEMA_VERSION: u8 = 1;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookPhase {
    Before,
    After,
    AfterAssistant,
    AfterReasoning,
}

impl HookPhase {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Before => "before_tool",
            Self::After => "after_tool",
            Self::AfterAssistant => "after_assistant",
            Self::AfterReasoning => "after_reasoning",
        }
    }

    /// Phases that can inject provider-visible context via `context_items`.
    pub(crate) fn supports_context_injection(self) -> bool {
        matches!(
            self,
            Self::After | Self::AfterAssistant | Self::AfterReasoning
        )
    }

    /// Whether the target event has already occurred when the hook fires.
    pub(crate) fn is_after_event(self) -> bool {
        matches!(
            self,
            Self::After | Self::AfterAssistant | Self::AfterReasoning
        )
    }

    /// Whether `include_tools`/`exclude_tools` filtering applies to this phase.
    pub(crate) fn uses_tool_filter(self) -> bool {
        matches!(self, Self::Before | Self::After)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookFailureCategory {
    Exit,
    Timeout,
    Runner,
    Stdin,
    OutputLimit,
}

impl HookFailureCategory {
    pub(crate) fn as_str(&self) -> &'static str {
        match self {
            Self::Exit => "exit",
            Self::Timeout => "timeout",
            Self::Runner => "runner",
            Self::Stdin => "stdin",
            Self::OutputLimit => "output-limit",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookLifecycleStatus {
    Started,
    Success,
    Failed,
}

impl HookLifecycleStatus {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Started => "started",
            Self::Success => "success",
            Self::Failed => "failed",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookLifecycleRecord {
    pub(crate) phase: HookPhase,
    pub(crate) target_tool: String,
    pub(crate) status: HookLifecycleStatus,
    pub(crate) policy: HookFailurePolicy,
    pub(crate) label: String,
    pub(crate) category: Option<HookFailureCategory>,
    pub(crate) target_ran: bool,
    pub(crate) tool_call_id: Option<String>,
    pub(crate) activity_id: Option<String>,
    pub(crate) hook_index: usize,
    pub(crate) elapsed_ms: Option<u128>,
    pub(crate) message: Option<String>,
}

impl HookLifecycleRecord {
    pub(super) fn started(
        phase: HookPhase,
        call: &ToolCall,
        hook: &HookDefinition,
        hook_index: usize,
        context: Option<&ToolDispatchContext>,
        target_ran: bool,
        default_policy: HookFailurePolicy,
    ) -> Self {
        let activity_id = Some(hook_activity_id(
            context.and_then(|context| context.parent_activity_id.as_ref()),
            phase,
            call,
            hook_index,
        ))
        .map(|id| id.as_str().to_string());
        let tool_call_id = (!call.id.trim().is_empty()).then(|| redact_sensitive_text(&call.id));
        Self {
            phase,
            target_tool: redact_sensitive_text(&call.name),
            status: HookLifecycleStatus::Started,
            policy: hook.failure_policy.unwrap_or(default_policy),
            label: redact_sensitive_text(&hook.effective_label()),
            category: None,
            target_ran,
            tool_call_id,
            activity_id,
            hook_index,
            elapsed_ms: None,
            message: None,
        }
    }

    pub(super) fn success(mut self, elapsed_ms: Option<u128>) -> Self {
        self.status = HookLifecycleStatus::Success;
        self.elapsed_ms = elapsed_ms;
        self
    }

    pub(super) fn failed(mut self, diagnostic: &HookDiagnostic, elapsed_ms: Option<u128>) -> Self {
        self.status = HookLifecycleStatus::Failed;
        self.category = Some(diagnostic.category);
        self.policy = diagnostic.policy;
        self.elapsed_ms = elapsed_ms;
        self.message = Some(bounded_lifecycle_message(&diagnostic.sanitized_message()));
        self
    }

    pub(crate) fn to_session_payload(&self) -> Value {
        json!({
            "phase": self.phase.as_str(),
            "label": self.label,
            "target_tool": self.target_tool,
            "status": self.status.as_str(),
            "policy": self.policy.as_str(),
            "category": self.category.as_ref().map(HookFailureCategory::as_str),
            "target_ran": self.target_ran,
            "tool_call_id": self.tool_call_id,
            "activity_id": self.activity_id,
            "hook_index": self.hook_index,
            "elapsed_ms": self.elapsed_ms,
            "message": self.message,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookContextInjectionStatus {
    Success,
    InvalidJson,
    InvalidShape,
    UnsupportedRole,
    EmptyContent,
    OverLimit,
    HookFailed,
}

impl HookContextInjectionStatus {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Success => "success",
            Self::InvalidJson => "invalid_json",
            Self::InvalidShape => "invalid_shape",
            Self::UnsupportedRole => "unsupported_role",
            Self::EmptyContent => "empty_content",
            Self::OverLimit => "over_limit",
            Self::HookFailed => "hook_failed",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookContextInjectionRecord {
    pub(crate) phase: HookPhase,
    pub(crate) label: String,
    pub(crate) target_tool: String,
    pub(crate) status: HookContextInjectionStatus,
    pub(crate) item_count: usize,
    pub(crate) byte_count: usize,
    pub(crate) max_bytes: usize,
    pub(crate) hook_index: usize,
    pub(crate) tool_call_id: Option<String>,
    pub(crate) injection_id: String,
}

pub(super) struct HookContextInjectionRecordInput<'a> {
    pub(super) phase: HookPhase,
    pub(super) call: &'a ToolCall,
    pub(super) hook: &'a HookDefinition,
    pub(super) hook_index: usize,
    pub(super) status: HookContextInjectionStatus,
    pub(super) item_count: usize,
    pub(super) byte_count: usize,
    pub(super) max_bytes: usize,
}

impl HookContextInjectionRecord {
    pub(super) fn new(input: HookContextInjectionRecordInput<'_>) -> Self {
        let target_tool = redact_sensitive_text(&input.call.name);
        let tool_call_id =
            (!input.call.id.trim().is_empty()).then(|| redact_sensitive_text(&input.call.id));
        let target_id = tool_call_id.as_deref().unwrap_or(&target_tool);
        let injection_id = redact_sensitive_text(&format!(
            "{}:{}:{}",
            input.phase.as_str(),
            target_id,
            input.hook_index
        ));
        Self {
            phase: input.phase,
            label: redact_sensitive_text(&input.hook.effective_label()),
            target_tool,
            status: input.status,
            item_count: input.item_count,
            byte_count: input.byte_count,
            max_bytes: input.max_bytes,
            hook_index: input.hook_index,
            tool_call_id,
            injection_id,
        }
    }

    pub(crate) fn to_session_payload(&self) -> Value {
        json!({
            "schema_version": HOOK_PROVIDER_CONTEXT_SCHEMA_VERSION,
            "phase": self.phase.as_str(),
            "label": self.label,
            "target_tool": self.target_tool,
            "status": self.status.as_str(),
            "item_count": self.item_count,
            "byte_count": self.byte_count,
            "max_bytes": self.max_bytes,
            "hook_index": self.hook_index,
            "tool_call_id": self.tool_call_id,
            "injection_id": self.injection_id,
        })
    }
}

fn bounded_lifecycle_message(message: &str) -> String {
    let redacted = redact_sensitive_text(message);
    let mut chars = redacted.chars();
    let truncated: String = chars
        .by_ref()
        .take(HOOK_LIFECYCLE_MESSAGE_MAX_CHARS)
        .collect();
    if chars.next().is_some() {
        format!("{truncated}")
    } else {
        truncated
    }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookDiagnostic {
    pub(crate) phase: HookPhase,
    pub(crate) tool_name: String,
    pub(crate) label: String,
    pub(crate) category: HookFailureCategory,
    pub(crate) policy: HookFailurePolicy,
    pub(crate) target_ran: bool,
    pub(crate) message: String,
}

impl HookDiagnostic {
    pub(crate) fn sanitized_message(&self) -> String {
        redact_sensitive_text(&self.message)
    }
}

#[derive(Debug, Clone)]
pub(crate) struct HookPolicyError {
    diagnostic: HookDiagnostic,
}

impl HookPolicyError {
    pub(crate) fn new(diagnostic: HookDiagnostic) -> Self {
        Self { diagnostic }
    }

    pub(crate) fn parent_safe_summary(&self) -> &'static str {
        "child subagent stopped by local hook policy; inspect child session JSONL for local hook details"
    }
}

impl fmt::Display for HookPolicyError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.diagnostic.sanitized_message())
    }
}

impl Error for HookPolicyError {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookAction {
    Continue,
    Block(HookDiagnostic),
    Fail(HookDiagnostic),
}

#[derive(Debug)]
pub(crate) struct HookOutcome {
    pub(crate) action: HookAction,
    pub(crate) diagnostics: Vec<HookDiagnostic>,
    pub(crate) lifecycle_records: Vec<HookLifecycleRecord>,
    pub(crate) context_items: Vec<ProviderConversationItem>,
    pub(crate) context_injection_records: Vec<HookContextInjectionRecord>,
    pub(crate) canceled: bool,
}

impl HookOutcome {
    pub(super) fn continue_with(
        diagnostics: Vec<HookDiagnostic>,
        lifecycle_records: Vec<HookLifecycleRecord>,
    ) -> Self {
        Self {
            action: HookAction::Continue,
            diagnostics,
            lifecycle_records,
            context_items: Vec::new(),
            context_injection_records: Vec::new(),
            canceled: false,
        }
    }
}