Skip to main content

trusty_memory/
hook_emit.rs

1//! Cross-process hook activity emit.
2//!
3//! Why: Claude Code's hook commands (`UserPromptSubmit` → `prompt-context`,
4//! `SessionStart` → `inbox-check`) run as ephemeral CLI subprocesses, not
5//! inside the long-lived daemon. They cannot call `state.emit` directly
6//! because they hold no `AppState`. Prior to this module they had no way
7//! to populate the activity feed, which led directly to the user
8//! complaint "the TUI activity feed is always empty in a normal Claude
9//! Code session" — because in a normal session the only daemon traffic
10//! is hooks, and hooks emitted nothing.
11//!
12//! What: this module exposes [`post_hook_event`] — a best-effort async
13//! helper that resolves the running daemon's HTTP address via
14//! `trusty_common::read_daemon_addr` and POSTs the hook payload to
15//! `POST /api/v1/activity/hook`. Failures are swallowed (warn-logged to
16//! stderr) so the hook never fails because of a missing or unresponsive
17//! daemon — that contract matches the prompt-context handler's "always
18//! exit 0" rule. The receiving daemon side lives in `web.rs` and
19//! forwards the payload to `state.emit(DaemonEvent::HookFired { … })`.
20//!
21//! Test: `post_hook_event_no_daemon_is_noop` (the no-daemon branch);
22//! the live-daemon round trip is covered in the prompt-context /
23//! inbox-check integration tests.
24
25use crate::{HookType, InjectionKind};
26use std::time::Duration;
27
28/// HTTP path for the hook ingestion endpoint.
29///
30/// Why: kept as a constant so tests can target it without copy-pasting
31/// the string. Mounted under `/api/v1/activity/hook` so it sits next to
32/// the existing `GET /api/v1/activity` history endpoint (#96).
33pub const HOOK_EVENT_PATH: &str = "/api/v1/activity/hook";
34
35/// Connect + total timeout for the hook emit POST.
36///
37/// Why: hooks run in front of every user prompt; the budget here must be
38/// tighter than the prompt-context fetch budget so a slow daemon never
39/// adds noticeable latency to the user's typing flow. 1.5 s is enough
40/// for a healthy local daemon plus a wide margin and tight enough that
41/// a hung daemon doesn't block Claude Code by more than a moment.
42const HOOK_EMIT_TIMEOUT: Duration = Duration::from_millis(1500);
43
44/// JSON payload posted to `POST /api/v1/activity/hook`.
45///
46/// Why: deliberately separate from `DaemonEvent` itself so we can evolve
47/// the wire format (add fields, rename) without breaking the SSE consumer
48/// schema. The daemon-side handler maps this into the canonical
49/// `DaemonEvent::HookFired` variant. Forwards-compatible: serde
50/// `#[serde(default)]` on every optional field means a future client can
51/// add fields without breaking older daemons.
52/// What: serde-encoded as snake_case JSON.
53/// Test: round-trip exercised by `post_hook_event_no_daemon_is_noop` (the
54/// payload encode is the only thing that runs).
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct HookEventPayload {
57    #[serde(default)]
58    pub palace_id: Option<String>,
59    #[serde(default)]
60    pub palace_name: Option<String>,
61    pub hook_type: HookType,
62    pub injection_kind: InjectionKind,
63    #[serde(default)]
64    pub injection_length: u64,
65    #[serde(default)]
66    pub trigger_prompt_excerpt: String,
67    #[serde(default)]
68    pub duration_ms: u64,
69}
70
71/// Post a hook event to the running daemon, best-effort.
72///
73/// Why: the contract for every hook handler is "never block the user's
74/// prompt because of a daemon problem". This function therefore swallows
75/// every error path — no daemon address discovered, HTTP client build
76/// error, POST send error, non-2xx response — and warn-logs the failure
77/// to stderr so the hook command itself continues to print whatever
78/// stdout the user expected.
79///
80/// What: resolves the daemon address via
81/// `trusty_common::read_daemon_addr("trusty-memory")`, builds a short-
82/// timeout `reqwest::Client`, POSTs the payload as JSON. Returns `()`
83/// regardless of outcome.
84///
85/// Test: `post_hook_event_no_daemon_is_noop` confirms the no-daemon
86/// branch is a no-op; the live-daemon path is exercised by
87/// `hook_fired_activity_emit_smoke` in `commands::prompt_context`.
88pub async fn post_hook_event(payload: HookEventPayload) {
89    // 1. Discover the daemon address. Missing lockfile / discovery error =
90    //    daemon is not running. The activity feed is best-effort — silently
91    //    return.
92    let addr = match trusty_common::read_daemon_addr("trusty-memory") {
93        Ok(Some(a)) => a,
94        Ok(None) => return,
95        Err(_) => return,
96    };
97    let base = if addr.starts_with("http://") || addr.starts_with("https://") {
98        addr
99    } else {
100        format!("http://{addr}")
101    };
102    let url = format!("{base}{HOOK_EVENT_PATH}");
103
104    // 2. Build a tightly-bounded HTTP client. A client-build failure is
105    //    a programmer-class problem (no realistic runtime trigger) but we
106    //    still degrade rather than panic — the hook must not fail.
107    let client = match reqwest::Client::builder()
108        .timeout(HOOK_EMIT_TIMEOUT)
109        .connect_timeout(HOOK_EMIT_TIMEOUT)
110        .build()
111    {
112        Ok(c) => c,
113        Err(e) => {
114            tracing::warn!("hook_emit: build client failed: {e:#}");
115            return;
116        }
117    };
118
119    // 3. Fire and forget. Any error is swallowed with a stderr warn so
120    //    operators chasing missing activity rows can find the failure
121    //    in `~/Library/Logs/trusty-memory/*.log` (or wherever the daemon
122    //    routed stderr) without the hook itself blowing up.
123    match client.post(&url).json(&payload).send().await {
124        Ok(resp) if resp.status().is_success() => {}
125        Ok(resp) => {
126            tracing::warn!("hook_emit: daemon returned {} for {url}", resp.status());
127        }
128        Err(e) => {
129            tracing::warn!("hook_emit: POST {url} failed: {e:#}");
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    /// Why: the hook handlers rely on this function being a no-op when
139    /// no daemon is running. A panic / error here would fail the hook
140    /// and break every Claude Code prompt on a host where the daemon
141    /// was never started.
142    /// What: pins a tempdir as the data dir so `read_daemon_addr`
143    /// returns `Ok(None)`, then awaits `post_hook_event`. Must return
144    /// without panicking.
145    /// Test: itself.
146    #[tokio::test]
147    async fn post_hook_event_no_daemon_is_noop() {
148        let _guard = crate::commands::env_test_lock().lock().await;
149        let tmp = tempfile::tempdir().expect("tempdir");
150        // SAFETY: test serialised by env_test_lock.
151        unsafe {
152            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
153        }
154        let payload = HookEventPayload {
155            palace_id: None,
156            palace_name: None,
157            hook_type: HookType::UserPromptSubmit,
158            injection_kind: InjectionKind::PromptContext,
159            injection_length: 0,
160            trigger_prompt_excerpt: String::new(),
161            duration_ms: 1,
162        };
163        // Must not panic / hang.
164        post_hook_event(payload).await;
165        unsafe {
166            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
167        }
168    }
169}