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
19fn delivery_block_on<F, O>(fut: F) -> Option<O>
24where
25 F: std::future::Future<Output = O> + Send,
26 O: Send,
27{
28 let rt = delivery_runtime().ok()?;
29 if tokio::runtime::Handle::try_current().is_ok() {
30 let mut result = None;
31 std::thread::scope(|s| {
32 s.spawn(|| {
33 result = Some(rt.block_on(fut));
34 });
35 });
36 result
37 } else {
38 Some(rt.block_on(fut))
39 }
40}
41
42pub async fn daemon_request(method: &str, path: &str, body: &str) -> Result<String> {
45 use tokio::time::timeout;
46
47 const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
48 const IO_TIMEOUT: Duration = Duration::from_secs(10);
49
50 let addr = daemon::daemon_addr();
51 if !addr.is_listening() {
52 anyhow::bail!(
53 "Daemon endpoint not found at {}. Is the daemon running?",
54 addr.display()
55 );
56 }
57
58 let request = format_http_request(method, path, body);
59
60 #[cfg(unix)]
61 {
62 let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
63 .await
64 .with_context(|| {
65 format!(
66 "connect to daemon timed out ({}s)",
67 CONNECT_TIMEOUT.as_secs()
68 )
69 })?
70 .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
71
72 timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
73 .await
74 .context("write to daemon timed out")?
75 .context("failed to write request to daemon")?;
76
77 let mut response_buf = Vec::with_capacity(4096);
78 timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
79 .await
80 .context("read from daemon timed out")?
81 .context("failed to read response from daemon")?;
82
83 parse_http_response(&response_buf)
84 }
85
86 #[cfg(windows)]
87 {
88 let mut stream = timeout(CONNECT_TIMEOUT, ipc::connect(&addr))
89 .await
90 .with_context(|| {
91 format!(
92 "connect to daemon timed out ({}s)",
93 CONNECT_TIMEOUT.as_secs()
94 )
95 })?
96 .with_context(|| format!("cannot connect to daemon at {}", addr.display()))?;
97
98 timeout(IO_TIMEOUT, stream.write_all(request.as_bytes()))
99 .await
100 .context("write to daemon timed out")?
101 .context("failed to write request to daemon")?;
102
103 let mut response_buf = Vec::with_capacity(4096);
104 timeout(IO_TIMEOUT, stream.read_to_end(&mut response_buf))
105 .await
106 .context("read from daemon timed out")?
107 .context("failed to read response from daemon")?;
108
109 parse_http_response(&response_buf)
110 }
111}
112
113pub async fn daemon_health_check() -> bool {
115 match daemon_request("GET", "/health", "").await {
116 Ok(body) => body.trim() == "ok",
117 Err(_) => false,
118 }
119}
120
121pub async fn daemon_tool_call(name: &str, arguments: Option<&serde_json::Value>) -> Result<String> {
123 let body = serde_json::json!({
124 "name": name,
125 "arguments": arguments,
126 });
127 daemon_request("POST", "/v1/tools/call", &body.to_string()).await
128}
129
130fn format_http_request(method: &str, path: &str, body: &str) -> String {
131 if body.is_empty() {
132 format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
133 } else {
134 let content_length = body.len();
135 format!(
136 "{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}"
137 )
138 }
139}
140
141fn parse_http_response(raw: &[u8]) -> Result<String> {
142 let response_str = std::str::from_utf8(raw).context("daemon response is not valid UTF-8")?;
143
144 let Some(header_end) = response_str.find("\r\n\r\n") else {
145 anyhow::bail!("malformed HTTP response from daemon (no header boundary)");
146 };
147
148 let headers = &response_str[..header_end];
149 let body = &response_str[header_end + 4..];
150
151 let status_line = headers.lines().next().unwrap_or("");
152 let status_code = status_line
153 .split_whitespace()
154 .nth(1)
155 .and_then(|s| s.parse::<u16>().ok())
156 .unwrap_or(0);
157
158 if status_code >= 400 {
159 anyhow::bail!("daemon returned HTTP {status_code}: {body}");
160 }
161
162 Ok(body.to_string())
163}
164
165pub async fn try_daemon_request(method: &str, path: &str, body: &str) -> Option<String> {
167 if !daemon::is_daemon_running() {
168 return None;
169 }
170 daemon_request(method, path, body).await.ok()
171}
172
173pub fn notify_cache_clear() -> bool {
179 if !daemon::is_daemon_running() {
180 return false;
181 }
182 let Ok(rt) = tokio::runtime::Runtime::new() else {
183 return false;
184 };
185 let body = serde_json::json!({
186 "name": "ctx_cache",
187 "arguments": { "action": "clear" },
188 });
189 rt.block_on(async {
190 try_daemon_request("POST", "/v1/tools/call", &body.to_string())
191 .await
192 .is_some()
193 })
194}
195
196#[allow(clippy::needless_pass_by_value)]
215pub fn try_daemon_tool_call_blocking(
216 name: &str,
217 arguments: Option<serde_json::Value>,
218) -> Option<String> {
219 use std::time::Duration;
220
221 if std::env::var_os("__LEAN_CTX_NO_DAEMON").is_some() {
222 return None;
223 }
224
225 let rt = Runtime::new().ok()?;
226
227 let addr = daemon::daemon_addr();
228 let mut ready = addr.is_listening() && rt.block_on(async { daemon_health_check().await });
229
230 if !ready {
231 if crate::core::runtime_flags::hook_child_enabled() {
238 return None;
239 }
240
241 let lock = crate::core::startup_guard::try_acquire_lock(
242 "daemon-start",
243 Duration::from_millis(1200),
244 Duration::from_secs(5),
245 );
246
247 if let Some(g) = lock {
248 g.touch();
249 let mut did_start = false;
250
251 if !daemon::is_daemon_running() {
252 if daemon::start_daemon(&[]).is_ok() {
253 did_start = true;
254 } else {
255 return None;
256 }
257 }
258
259 for _ in 0..60 {
260 if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
261 ready = true;
262 break;
263 }
264 std::thread::sleep(Duration::from_millis(50));
265 }
266
267 if ready && did_start && crate::core::protocol::meta_visible() {
268 eprintln!("\x1b[2m▸ daemon auto-started\x1b[0m");
269 }
270 } else {
271 for _ in 0..60 {
272 if addr.is_listening() && rt.block_on(async { daemon_health_check().await }) {
273 ready = true;
274 break;
275 }
276 std::thread::sleep(Duration::from_millis(50));
277 }
278 }
279 }
280
281 if !ready {
282 return None;
283 }
284
285 if let Some(out) = rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
286 {
287 return Some(out);
288 }
289
290 for _ in 0..5 {
291 std::thread::sleep(Duration::from_millis(50));
292 if let Some(out) =
293 rt.block_on(async { daemon_tool_call(name, arguments.as_ref()).await.ok() })
294 {
295 return Some(out);
296 }
297 }
298
299 None
300}
301
302fn unwrap_mcp_tool_text(body: &str) -> Option<String> {
303 let v: serde_json::Value = serde_json::from_str(body).ok()?;
304 let result = v.get("result")?;
305
306 if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
307 let mut texts: Vec<String> = Vec::new();
308 for item in content {
309 if let Some(text) = item.get("text").and_then(|t| t.as_str())
310 && !text.is_empty()
311 {
312 texts.push(text.to_string());
313 }
314 }
315 if !texts.is_empty() {
316 return Some(texts.join("\n"));
317 }
318 }
319
320 if let Some(text) = result.get("text").and_then(|t| t.as_str()) {
321 return Some(text.to_string());
322 }
323
324 result.as_str().map(std::string::ToString::to_string)
325}
326
327pub fn try_daemon_tool_call_blocking_text(
329 name: &str,
330 arguments: Option<serde_json::Value>,
331) -> Option<String> {
332 let body = try_daemon_tool_call_blocking(name, arguments)?;
333 let trimmed = body.trim_start();
334 if !trimmed.starts_with('{') {
335 return Some(body);
336 }
337 Some(unwrap_mcp_tool_text(&body).unwrap_or(body))
338}
339
340pub fn try_delivery_check_blocking(
344 blake3: &[u8; 12],
345 mtime: u64,
346 path: &str,
347 requester_agent_id: Option<&str>,
348 requester_conversation_id: Option<&str>,
349) -> Option<crate::core::ocla::types::DeliveryRecord> {
350 if !daemon::is_daemon_running() {
351 return None;
352 }
353 let body = serde_json::json!({
354 "blake3": blake3,
355 "mtime": mtime,
356 "path": path,
357 "requester_agent_id": requester_agent_id,
358 "requester_conversation_id": requester_conversation_id,
359 });
360 let body_str = body.to_string();
361 let resp = delivery_block_on(async move {
362 try_daemon_request("POST", "/ocla/v1/delivery/check", &body_str).await
363 })??;
364 let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
365 if !v.get("hit")?.as_bool()? {
366 return None;
367 }
368 Some(crate::core::ocla::types::DeliveryRecord {
369 blake3: *blake3,
370 path: v.get("path")?.as_str()?.to_string(),
371 line_count: v.get("line_count")?.as_u64()? as u32,
372 token_count: v.get("token_count").and_then(serde_json::Value::as_u64)?,
373 agent_id: v.get("agent_id")?.as_str()?.to_string(),
374 conversation_id: v.get("conversation_id")?.as_str()?.to_string(),
375 read_at: v.get("read_at")?.as_u64()?,
376 mtime: v
377 .get("mtime")
378 .and_then(serde_json::Value::as_u64)
379 .unwrap_or(mtime),
380 fresh: v
381 .get("fresh")
382 .and_then(serde_json::Value::as_bool)
383 .unwrap_or(true),
384 relay_content: v
385 .get("relay_content")
386 .and_then(serde_json::Value::as_str)
387 .map(String::from),
388 relay_mode: v
389 .get("relay_mode")
390 .and_then(serde_json::Value::as_str)
391 .map(String::from),
392 })
393}
394
395pub fn try_delivery_record_blocking(entry: &crate::core::ocla::types::DeliveryEntry) {
398 if !daemon::is_daemon_running() {
399 return;
400 }
401 let Ok(body) = serde_json::to_string(entry) else {
402 return;
403 };
404 let Ok(rt) = delivery_runtime() else {
405 return;
406 };
407 drop(rt.spawn(async move {
408 let _ = tokio::time::timeout(
409 Duration::from_secs(3),
410 try_daemon_request("POST", "/ocla/v1/delivery/record", &body),
411 )
412 .await;
413 }));
414}
415
416pub fn try_cache_check_blocking(
419 key: &crate::core::ocla::cache_types::CacheKey,
420 validator: &crate::core::ocla::cache_types::CacheValidator,
421 requester_agent_id: Option<&str>,
422 requester_conversation_id: Option<&str>,
423) -> Option<crate::core::ocla::cache_types::DeliveryEntryV2> {
424 if !daemon::is_daemon_running() {
425 return None;
426 }
427 let validator_str = match validator {
428 crate::core::ocla::cache_types::CacheValidator::Immutable => "immutable".into(),
429 crate::core::ocla::cache_types::CacheValidator::File { mtime_ns } => {
430 format!("file:{mtime_ns}")
431 }
432 crate::core::ocla::cache_types::CacheValidator::Directory { mtime_ns } => {
433 format!("directory:{mtime_ns}")
434 }
435 };
436 let body = serde_json::json!({
437 "key": key.0,
438 "validator": validator_str,
439 "requester_agent_id": requester_agent_id,
440 "requester_conversation_id": requester_conversation_id,
441 });
442 let body_str = body.to_string();
443 let resp = delivery_block_on(async move {
444 try_daemon_request("POST", "/ocla/v1/cache/check", &body_str).await
445 })??;
446 let v: serde_json::Value = serde_json::from_str(&resp).ok()?;
447 if !v.get("hit")?.as_bool()? {
448 return None;
449 }
450 serde_json::from_value(v.get("entry")?.clone()).ok()
451}
452
453pub fn try_cache_record_blocking(entry: &crate::core::ocla::cache_types::DeliveryEntryV2) {
455 if !daemon::is_daemon_running() {
456 return;
457 }
458 let Ok(body) = serde_json::to_string(entry) else {
459 return;
460 };
461 let Ok(rt) = delivery_runtime() else { return };
462 drop(rt.spawn(async move {
463 let _ = tokio::time::timeout(
464 Duration::from_secs(3),
465 try_daemon_request("POST", "/ocla/v1/cache/record", &body),
466 )
467 .await;
468 }));
469}
470
471#[cfg(test)]
472mod tests {
473 use super::delivery_runtime;
474
475 #[test]
476 fn delivery_runtime_is_shared() {
477 let first = delivery_runtime().expect("shared delivery runtime initializes");
478 let second = delivery_runtime().expect("shared delivery runtime remains available");
479 assert!(std::ptr::eq(first, second));
480 }
481}