Skip to main content

lean_ctx/
daemon_client.rs

1use anyhow::{Context, Result};
2use std::sync::OnceLock;
3use std::time::Duration;
4use tokio::io::{AsyncReadExt, AsyncWriteExt};
5use tokio::runtime::Runtime;
6
7use crate::daemon;
8use crate::ipc;
9
10static DELIVERY_RUNTIME: OnceLock<Result<Runtime, String>> = OnceLock::new();
11
12fn delivery_runtime() -> Result<&'static Runtime> {
13    match DELIVERY_RUNTIME.get_or_init(|| Runtime::new().map_err(|error| error.to_string())) {
14        Ok(runtime) => Ok(runtime),
15        Err(error) => Err(anyhow::anyhow!("initialize delivery IPC runtime: {error}")),
16    }
17}
18
19/// Send an HTTP request to the daemon over the IPC channel.
20/// Returns the response body as a string.
21pub async fn daemon_request(method: &str, path: &str, body: &str) -> Result<String> {
22    use tokio::time::timeout;
23
24    const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
25    const IO_TIMEOUT: Duration = Duration::from_secs(10);
26
27    let addr = daemon::daemon_addr();
28    if !addr.is_listening() {
29        anyhow::bail!(
30            "Daemon endpoint not found at {}. Is the daemon running?",
31            addr.display()
32        );
33    }
34
35    let request = format_http_request(method, path, body);
36
37    #[cfg(unix)]
38    {
39        let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
40            .await
41            .with_context(|| {
42                format!(
43                    "connect to daemon timed out ({}s)",
44                    CONNECT_TIMEOUT.as_secs()
45                )
46            })?
47            .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
48
49        timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
50            .await
51            .context("write to daemon timed out")?
52            .context("failed to write request to daemon")?;
53
54        let mut response_buf = Vec::with_capacity(4096);
55        timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
56            .await
57            .context("read from daemon timed out")?
58            .context("failed to read response from daemon")?;
59
60        parse_http_response(&response_buf)
61    }
62
63    #[cfg(windows)]
64    {
65        let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
66            .await
67            .with_context(|| {
68                format!(
69                    "connect to daemon timed out ({}s)",
70                    CONNECT_TIMEOUT.as_secs()
71                )
72            })?
73            .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
74
75        timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
76            .await
77            .context("write to daemon timed out")?
78            .context("failed to write request to daemon")?;
79
80        let mut response_buf = Vec::with_capacity(4096);
81        timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
82            .await
83            .context("read from daemon timed out")?
84            .context("failed to read response from daemon")?;
85
86        parse_http_response(&response_buf)
87    }
88}
89
90/// Check if the daemon is reachable by hitting /health.
91pub async fn daemon_health_check() -> bool {
92    match daemon_request("GET", "/health", "").await {
93        Ok(body) => body.trim() == "ok",
94        Err(_) => false,
95    }
96}
97
98/// Call a tool on the daemon's REST API.
99pub async fn daemon_tool_call(name: &str, arguments: Option<&serde_json::Value>) -> Result<String> {
100    let body = serde_json::json!({
101        "name": name,
102        "arguments": arguments,
103    });
104    daemon_request("POST", "/v1/tools/call", &body.to_string()).await
105}
106
107fn format_http_request(method: &str, path: &str, body: &str) -> String {
108    if body.is_empty() {
109        format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
110    } else {
111        let content_length = body.len();
112        format!(
113            "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {content_length}\r\nConnection: close\r\n\r\n{body}"
114        )
115    }
116}
117
118fn parse_http_response(raw: &[u8]) -> Result<String> {
119    let response_str = std::str::from_utf8(raw).context("daemon response is not valid UTF-8")?;
120
121    let Some(header_end) = response_str.find("\r\n\r\n") else {
122        anyhow::bail!("malformed HTTP response from daemon (no header boundary)");
123    };
124
125    let headers = &response_str[..header_end];
126    let body = &response_str[header_end + 4..];
127
128    let status_line = headers.lines().next().unwrap_or("");
129    let status_code = status_line
130        .split_whitespace()
131        .nth(1)
132        .and_then(|s| s.parse::<u16>().ok())
133        .unwrap_or(0);
134
135    if status_code >= 400 {
136        anyhow::bail!("daemon returned HTTP {status_code}: {body}");
137    }
138
139    Ok(body.to_string())
140}
141
142/// Attempt to connect to the daemon. Returns `None` if not running.
143pub async fn try_daemon_request(method: &str, path: &str, body: &str) -> Option<String> {
144    if !daemon::is_daemon_running() {
145        return None;
146    }
147    daemon_request(method, path, body).await.ok()
148}
149
150/// Tell a *running* daemon to drop its in-memory read cache (`SessionCache`).
151/// Returns `true` if a daemon was reached. Never auto-starts a daemon — if none
152/// is running there is no cache to flush. Force-rebuild CLI commands call this so
153/// `ctx_read` map/signatures stop serving pre-rebuild output from the daemon's
154/// long-lived cache, which CLI index rebuilds otherwise can't reach (#420).
155pub fn notify_cache_clear() -> bool {
156    if !daemon::is_daemon_running() {
157        return false;
158    }
159    let Ok(rt) = tokio::runtime::Runtime::new() else {
160        return false;
161    };
162    let body = serde_json::json!({
163        "name": "ctx_cache",
164        "arguments": { "action": "clear" },
165    });
166    rt.block_on(async {
167        try_daemon_request("POST", "/v1/tools/call", &body.to_string())
168            .await
169            .is_some()
170    })
171}
172
173/// Blocking helper for CLI commands: routes a tool call through the daemon.
174///
175/// Returns `None` if no daemon can serve the call (caller then renders the tool
176/// locally / standalone). Behaviour splits on caller identity:
177///
178/// - **Normal CLI invocation**: connects to a running daemon, and *auto-starts*
179///   one if none is listening (so the long-lived `LeanCtxServer` state — caches,
180///   indexes, detectors — is reused across commands).
181/// - **Shadow-mode hook child** (`LEAN_CTX_HOOK_CHILD` set): *connect-only*. It
182///   reuses an already-running daemon for full parity (the `ready` fast-path
183///   below routes straight through `/v1/tools/call` → `call_tool_guarded`, so the
184///   in-memory `LoopDetector`, correction-loop auto-degrade, bounce tracker and
185///   adaptive thresholds all fire on the daemon's long-lived state — #566), but
186///   it MUST NEVER auto-start a daemon. A hook fires once per intercepted
187///   read/grep as a fresh process; auto-starting from there would spawn daemons
188///   uncontrollably. With no live daemon it returns `None` and the caller falls
189///   back to the enriched standalone path (disk-backed learning sinks + Context
190///   IR from #550/#569).
191#[allow(clippy::needless_pass_by_value)]
192pub fn try_daemon_tool_call_blocking(
193    name: &str,
194    arguments: Option<serde_json::Value>,
195) -> Option<String> {
196    use std::time::Duration;
197
198    if std::env::var_os("__LEAN_CTX_NO_DAEMON").is_some() {
199        return None;
200    }
201
202    let rt = Runtime::new().ok()?;
203
204    let addr = daemon::daemon_addr();
205    let mut ready = addr.is_listening() && rt.block_on(async { daemon_health_check().await });
206
207    if !ready {
208        // Connect-only for shadow-mode hooks (#566): a hook child reaches a live
209        // daemon via the `ready` fast-path above (full detector parity), but when
210        // none is listening it must bail to the standalone fallback instead of
211        // auto-starting one. This guard MUST stay inside `if !ready` — hoisting it
212        // to the top of the function would also block hooks from reusing a running
213        // daemon, silently regressing loop/bounce/adaptive parity.
214        if crate::core::runtime_flags::hook_child_enabled() {
215            return None;
216        }
217
218        let lock = crate::core::startup_guard::try_acquire_lock(
219            "daemon-start",
220            Duration::from_millis(1200),
221            Duration::from_secs(5),
222        );
223
224        if let Some(g) = lock {
225            g.touch();
226            let mut did_start = false;
227
228            if !daemon::is_daemon_running() {
229                if daemon::start_daemon(&[]).is_ok() {
230                    did_start = true;
231                } else {
232                    return None;
233                }
234            }
235
236            for _ in 0..60 {
237                if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
238                    ready = true;
239                    break;
240                }
241                std::thread::sleep(Duration::from_millis(50));
242            }
243
244            if ready && did_start && crate::core::protocol::meta_visible() {
245                eprintln!("\x1b[2m▸ daemon auto-started\x1b[0m");
246            }
247        } else {
248            for _ in 0..60 {
249                if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
250                    ready = true;
251                    break;
252                }
253                std::thread::sleep(Duration::from_millis(50));
254            }
255        }
256    }
257
258    if !ready {
259        return None;
260    }
261
262    if let Some(out) = rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
263    {
264        return Some(out);
265    }
266
267    for _ in 0..5 {
268        std::thread::sleep(Duration::from_millis(50));
269        if let Some(out) =
270            rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
271        {
272            return Some(out);
273        }
274    }
275
276    None
277}
278
279fn unwrap_mcp_tool_text(body: &str) -> Option<String> {
280    let v: serde_json::Value = serde_json::from_str(body).ok()?;
281    let result = v.get("result")?;
282
283    if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
284        let mut texts: Vec<String> = Vec::new();
285        for item in content {
286            if let Some(text) = item.get("text").and_then(|t| t.as_str())
287                && !text.is_empty()
288            {
289                texts.push(text.to_string());
290            }
291        }
292        if !texts.is_empty() {
293            return Some(texts.join("\n"));
294        }
295    }
296
297    if let Some(text) = result.get("text").and_then(|t| t.as_str()) {
298        return Some(text.to_string());
299    }
300
301    result.as_str().map(std::string::ToString::to_string)
302}
303
304/// Like `try_daemon_tool_call_blocking`, but unwraps MCP JSON responses to text for CLI output.
305pub fn try_daemon_tool_call_blocking_text(
306    name: &str,
307    arguments: Option<serde_json::Value>,
308) -> Option<String> {
309    let body = try_daemon_tool_call_blocking(name, arguments)?;
310    let trimmed = body.trim_start();
311    if !trimmed.starts_with('{') {
312        return Some(body);
313    }
314    Some(unwrap_mcp_tool_text(&body).unwrap_or(body))
315}
316
317/// Check the daemon's cross-agent delivery registry for a content hash.
318/// Returns `None` if daemon unreachable or no hit. Connect-only — never
319/// auto-starts a daemon (delivery is best-effort).
320pub fn try_delivery_check_blocking(
321    blake3: &[u8; 12],
322    mtime: u64,
323    path: &str,
324    requester_agent_id: Option<&str>,
325    requester_conversation_id: Option<&str>,
326) -> Option<crate::core::ocla::types::DeliveryRecord> {
327    if !daemon::is_daemon_running() {
328        return None;
329    }
330    let rt = delivery_runtime().ok()?;
331    let body = serde_json::json!({
332        "blake3": blake3,
333        "mtime": mtime,
334        "path": path,
335        "requester_agent_id": requester_agent_id,
336        "requester_conversation_id": requester_conversation_id,
337    });
338    let resp = rt.block_on(async {
339        try_daemon_request("POST", "/ocla/v1/delivery/check", &body.to_string()).await
340    })?;
341    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
342    if !v.get("hit")?.as_bool()? {
343        return None;
344    }
345    Some(crate::core::ocla::types::DeliveryRecord {
346        blake3: *blake3,
347        path: v.get("path")?.as_str()?.to_string(),
348        line_count: v.get("line_count")?.as_u64()? as u32,
349        token_count: v.get("token_count").and_then(serde_json::Value::as_u64)?,
350        agent_id: v.get("agent_id")?.as_str()?.to_string(),
351        conversation_id: v.get("conversation_id")?.as_str()?.to_string(),
352        read_at: v.get("read_at")?.as_u64()?,
353        mtime: v
354            .get("mtime")
355            .and_then(serde_json::Value::as_u64)
356            .unwrap_or(mtime),
357        fresh: v
358            .get("fresh")
359            .and_then(serde_json::Value::as_bool)
360            .unwrap_or(true),
361    })
362}
363
364/// Record a delivery in the daemon's cross-agent registry.
365/// Fire-and-forget: errors and slow daemon responses are intentionally dropped.
366pub fn try_delivery_record_blocking(entry: &crate::core::ocla::types::DeliveryEntry) {
367    if !daemon::is_daemon_running() {
368        return;
369    }
370    let Ok(body) = serde_json::to_string(entry) else {
371        return;
372    };
373    let Ok(rt) = delivery_runtime() else {
374        return;
375    };
376    drop(rt.spawn(async move {
377        let _ = tokio::time::timeout(
378            Duration::from_secs(3),
379            try_daemon_request("POST", "/ocla/v1/delivery/record", &body),
380        )
381        .await;
382    }));
383}
384
385/// Check the daemon's generalized cross-agent cache (all DeliveryKinds).
386/// Returns the cached entry on hit, None on miss or daemon unreachable.
387pub fn try_cache_check_blocking(
388    key: &crate::core::ocla::cache_types::CacheKey,
389    validator: &crate::core::ocla::cache_types::CacheValidator,
390    requester_agent_id: Option<&str>,
391    requester_conversation_id: Option<&str>,
392) -> Option<crate::core::ocla::cache_types::DeliveryEntryV2> {
393    if !daemon::is_daemon_running() {
394        return None;
395    }
396    let rt = delivery_runtime().ok()?;
397    let validator_str = match validator {
398        crate::core::ocla::cache_types::CacheValidator::Immutable => "immutable".into(),
399        crate::core::ocla::cache_types::CacheValidator::File { mtime_ns } => {
400            format!("file:{mtime_ns}")
401        }
402        crate::core::ocla::cache_types::CacheValidator::Directory { mtime_ns } => {
403            format!("directory:{mtime_ns}")
404        }
405    };
406    let body = serde_json::json!({
407        "key": key.0,
408        "validator": validator_str,
409        "requester_agent_id": requester_agent_id,
410        "requester_conversation_id": requester_conversation_id,
411    });
412    let resp = rt.block_on(async {
413        try_daemon_request("POST", "/ocla/v1/cache/check", &body.to_string()).await
414    })?;
415    let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
416    if !v.get("hit")?.as_bool()? {
417        return None;
418    }
419    serde_json::from_value(v.get("entry")?.clone()).ok()
420}
421
422/// Record a generalized cache entry via daemon IPC. Fire-and-forget.
423pub fn try_cache_record_blocking(entry: &crate::core::ocla::cache_types::DeliveryEntryV2) {
424    if !daemon::is_daemon_running() {
425        return;
426    }
427    let Ok(body) = serde_json::to_string(entry) else {
428        return;
429    };
430    let Ok(rt) = delivery_runtime() else { return };
431    drop(rt.spawn(async move {
432        let _ = tokio::time::timeout(
433            Duration::from_secs(3),
434            try_daemon_request("POST", "/ocla/v1/cache/record", &body),
435        )
436        .await;
437    }));
438}
439
440#[cfg(test)]
441mod tests {
442    use super::delivery_runtime;
443
444    #[test]
445    fn delivery_runtime_is_shared() {
446        let first = delivery_runtime().expect("shared delivery runtime initializes");
447        let second = delivery_runtime().expect("shared delivery runtime remains available");
448        assert!(std::ptr::eq(first, second));
449    }
450}