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}