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
19pub 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
90pub 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
98pub 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
142pub 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
150pub 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#[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 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
304pub 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
317pub 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
364pub 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
385pub 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
422pub 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}