mod claude_config;
mod config;
pub mod deliverables;
mod error;
mod managed;
pub mod manager;
pub mod projects;
mod session_connect;
pub(crate) mod socket_routes;
pub mod supervisor;
#[cfg(test)]
mod tests;
mod transport;
mod types;
pub use transport::{DaemonCallError, DaemonRequest, DaemonResponse, resolve_daemon_socket};
pub(crate) use config::PROVISION_REQUEST_TIMEOUT;
pub use config::RECLAIM_SURVEY_REQUEST_TIMEOUT;
pub use config::DISK_SURVEY_REQUEST_TIMEOUT;
pub use types::{
BreakerRow, ChatMessage, ConfigRecommendation, CoordinatorChatOutcome, CoordinatorContext,
CoordinatorSession, DiscoveredProjectRow, EventRow, FleetByProjectWireResponse,
FleetProjectGroupWire, HealthSnapshot, LastSeen, LlmChatOutcome, ManagedActivityResponse,
ManagedAdoptRequest, ManagedAdoptResponse, ManagedAnswerRequest, ManagedAnswerResponse,
ManagedAttachCmdResponse, ManagedDecommissionOutcome, ManagedListResponse,
ManagedSendInputRequest, ManagedSendInputResponse, ManagedSessionSummary, ManagedSpawnRequest,
ManagedSpawnResponse, ManagedStoreHealth, OverseerSnapshot, PairConfirm, PairRequest,
PairStatus, SessionRow, TmuxSessionRow,
};
use serde::Deserialize;
pub fn default_client() -> reqwest::Client {
config::default_client()
}
#[derive(Debug, Clone)]
pub struct DaemonClient {
pub(in crate::client::http_client) base: String,
pub(in crate::client::http_client) http: reqwest::Client,
pub(in crate::client::http_client) home: Option<std::path::PathBuf>,
pub(in crate::client::http_client) socket: Option<std::path::PathBuf>,
pub(in crate::client::http_client) base_url_pinned: bool,
}
impl DaemonClient {
pub fn new(base: impl Into<String>) -> Self {
Self::with_client(config::default_client(), base)
}
pub fn with_client(client: reqwest::Client, base: impl Into<String>) -> Self {
Self {
base: base.into(),
http: client,
home: None,
socket: None,
base_url_pinned: false,
}
}
pub fn with_pinned_base_url(mut self, pinned: bool) -> Self {
self.base_url_pinned = pinned;
self
}
pub fn base_url_pinned(&self) -> bool {
self.base_url_pinned
}
#[cfg(test)]
pub(crate) fn with_home(mut self, home: &std::path::Path) -> Self {
self.home = Some(home.to_path_buf());
self
}
pub fn base_url(&self) -> &str {
&self.base
}
pub fn set_base_url(&mut self, base: impl Into<String>) {
self.base = base.into();
}
pub async fn sessions(&self) -> anyhow::Result<Vec<SessionRow>> {
#[derive(Deserialize)]
struct Body {
sessions: Vec<SessionRow>,
}
let url = "/sessions".to_string();
let body: Body = self.get(&url).send().await?.json().await?;
Ok(body.sessions)
}
pub async fn events(&self) -> anyhow::Result<Vec<EventRow>> {
#[derive(Deserialize)]
struct Body {
events: Vec<EventRow>,
}
let url = "/events/poll".to_string();
let body: Body = self.get(&url).send().await?.json().await?;
Ok(body.events)
}
pub async fn session_events(&self, id: &str) -> anyhow::Result<Vec<EventRow>> {
#[derive(Deserialize)]
struct Body {
events: Vec<EventRow>,
}
let url = format!("/sessions/{id}/events/poll");
let body: Body = self.get(&url).send().await?.json().await?;
Ok(body.events)
}
pub async fn breakers(&self) -> anyhow::Result<Vec<BreakerRow>> {
#[derive(Deserialize)]
struct WireBreaker {
state: String,
consecutive_failures: u32,
}
#[derive(Deserialize)]
struct WireRow {
agent: String,
breaker: WireBreaker,
}
#[derive(Deserialize)]
struct Body {
breakers: Vec<WireRow>,
}
let url = "/breakers".to_string();
let body: Body = self.get(&url).send().await?.json().await?;
Ok(body
.breakers
.into_iter()
.map(|r| BreakerRow {
agent: r.agent,
state: r.breaker.state,
consecutive_failures: r.breaker.consecutive_failures,
})
.collect())
}
pub async fn is_healthy(&self) -> bool {
let url = "/health".to_string();
matches!(self.get(&url).send().await, Ok(r) if r.status().is_success())
}
pub async fn catalog_stale(&self) -> bool {
let url = "/health".to_string();
let Ok(resp) = self.get(&url).send().await else {
return false;
};
if !resp.status().is_success() {
return false;
}
#[derive(Deserialize)]
struct HealthBody {
#[serde(default)]
catalog_stale: bool,
}
resp.json::<HealthBody>()
.await
.map(|b| b.catalog_stale)
.unwrap_or(false)
}
pub async fn health_snapshot(&self) -> anyhow::Result<HealthSnapshot> {
let url = "/health".to_string();
let snapshot = self
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(snapshot)
}
pub async fn health_snapshot_within(
&self,
timeout: std::time::Duration,
) -> Result<HealthSnapshot, DaemonCallError> {
let resp = self.get("/health").timeout(timeout).send().await?;
let failed = |source: anyhow::Error| DaemonCallError::Failed {
target: self.transport_label(),
source,
};
resp.error_for_status()
.map_err(failed)?
.json()
.await
.map_err(failed)
}
pub async fn pause_session(&self, id: &str) -> anyhow::Result<String> {
let url = format!("/sessions/{id}/pause");
let body: serde_json::Value = self
.post(&url)
.json(&serde_json::json!({ "summary": serde_json::Value::Null }))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn resume_session(&self, id: &str) -> anyhow::Result<()> {
let url = format!("/sessions/{id}/resume");
self.post(&url).send().await?.error_for_status()?;
Ok(())
}
pub async fn kill_session(&self, id: &str) -> anyhow::Result<bool> {
let url = format!("/sessions/{id}");
let resp = self.delete(&url).send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
resp.error_for_status()?;
Ok(true)
}
pub async fn stop_session(&self, id: &str) -> anyhow::Result<()> {
self.kill_session(id).await.map(|_| ())
}
pub async fn session_output(&self, id: &str, lines: u32) -> anyhow::Result<String> {
let url = format!("/sessions/{id}/output");
let body: serde_json::Value = self
.get(&url)
.query(&[("lines", lines)])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn send_session_command(
&self,
id: &str,
command: &str,
) -> anyhow::Result<Option<String>> {
let url = format!("/sessions/{id}/command");
let resp = self
.post(&url)
.json(&serde_json::json!({ "command": command }))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
Ok(Some(
body.get("output")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
))
}
pub async fn overseer_status(&self) -> anyhow::Result<OverseerSnapshot> {
let url = "/overseer".to_string();
let body: serde_json::Value = self.get(&url).send().await?.json().await?;
let o = &body["overseer"];
let decisions = &o["decisions"];
Ok(OverseerSnapshot {
enabled: o["enabled"].as_bool().unwrap_or(false),
handler: o["handler"].as_str().unwrap_or("?").to_string(),
decisions: (
decisions["allow"].as_u64().unwrap_or(0),
decisions["block"].as_u64().unwrap_or(0),
decisions["flag"].as_u64().unwrap_or(0),
),
})
}
pub async fn tmux_sessions(&self) -> anyhow::Result<Vec<TmuxSessionRow>> {
let url = "/tmux/sessions".to_string();
let body: serde_json::Value = self.get(&url).send().await?.json().await?;
let sessions = body["sessions"].as_array().cloned().unwrap_or_default();
Ok(sessions
.iter()
.filter_map(|s| {
let name = s
.get("name")
.and_then(|v| v.as_str())
.or_else(|| s.as_str())?;
let managed = s.get("origin").and_then(|v| v.as_str()) == Some("trusty_mpm");
Some(TmuxSessionRow {
name: name.to_string(),
managed,
})
})
.collect())
}
pub async fn discover_projects(&self) -> anyhow::Result<Vec<DiscoveredProjectRow>> {
#[derive(Deserialize)]
struct Body {
#[serde(default)]
projects: Vec<DiscoveredProjectRow>,
}
let url = "/projects/discover".to_string();
let body: Body = self.get(&url).send().await?.json().await?;
Ok(body.projects)
}
pub async fn register_project(&self, path: &str) -> anyhow::Result<()> {
let url = "/projects".to_string();
self.post(&url)
.json(&serde_json::json!({ "path": path }))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn snapshot_tmux_session(&self, name: &str) -> anyhow::Result<Option<String>> {
let url = format!("/tmux/sessions/{name}/snapshot");
let resp = self.get(&url).send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = resp.json().await?;
Ok(Some(snapshot_text(&body["snapshot"])))
}
pub async fn adopt_tmux_session(&self, name: &str) -> anyhow::Result<bool> {
let url = "/tmux/adopt".to_string();
let resp = self
.post(&url)
.json(&serde_json::json!({ "session": name }))
.send()
.await?;
Ok(resp.status().is_success())
}
pub async fn discover_sessions(&self) -> anyhow::Result<usize> {
let url = "/sessions/discover".to_string();
let body: serde_json::Value = self
.post(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body
.get("discovered")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize)
}
pub async fn pair_request(&self) -> anyhow::Result<PairRequest> {
let url = "/pair/request".to_string();
let body: PairRequest = self
.post(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body)
}
pub async fn pair_confirm(&self, code: &str, chat_id: i64) -> anyhow::Result<PairConfirm> {
let url = "/pair/confirm".to_string();
let body: PairConfirm = self
.post(&url)
.json(&serde_json::json!({ "code": code, "chat_id": chat_id }))
.send()
.await?
.json()
.await?;
Ok(body)
}
pub async fn llm_chat(
&self,
message: &str,
history: &[ChatMessage],
) -> anyhow::Result<Option<LlmChatOutcome>> {
let url = "/llm/chat".to_string();
let resp = self
.post(&url)
.json(&serde_json::json!({ "message": message, "history": history }))
.timeout(config::CHAT_REQUEST_TIMEOUT)
.send()
.await?;
if resp.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE {
return Ok(None);
}
let outcome: LlmChatOutcome = resp.error_for_status()?.json().await?;
Ok(Some(outcome))
}
pub async fn coordinator_context(&self) -> anyhow::Result<CoordinatorContext> {
let url = "/api/v1/sessions/context".to_string();
let context = self
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(context)
}
pub async fn coordinator_chat(
&self,
message: &str,
history: &[ChatMessage],
actions: bool,
) -> anyhow::Result<Option<CoordinatorChatOutcome>> {
let url = "/api/v1/sessions/chat".to_string();
let resp = self
.post(&url)
.json(&coordinator_chat_body(message, history, actions))
.timeout(config::CHAT_REQUEST_TIMEOUT)
.send()
.await?;
if resp.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE {
return Ok(None);
}
let outcome: CoordinatorChatOutcome = resp.error_for_status()?.json().await?;
Ok(Some(outcome))
}
pub async fn pair_status(&self) -> anyhow::Result<PairStatus> {
let url = "/pair/status".to_string();
let body: PairStatus = self.get(&url).send().await?.json().await?;
Ok(body)
}
pub async fn doctor(
&self,
project: Option<&str>,
) -> anyhow::Result<crate::core::doctor::DoctorReport> {
let url = "/api/v1/doctor".to_string();
let mut request = self.get(&url).timeout(config::DOCTOR_REQUEST_TIMEOUT);
if let Some(project) = project {
request = request.query(&[("project", project)]);
}
let report = request.send().await?.error_for_status()?.json().await?;
Ok(report)
}
}
fn coordinator_chat_body(
message: &str,
history: &[ChatMessage],
actions: bool,
) -> serde_json::Value {
serde_json::json!({
"message": message,
"history": history,
"actions": actions,
})
}
pub(crate) fn snapshot_text(snapshot: &serde_json::Value) -> String {
if let Some(s) = snapshot.as_str() {
return s.to_string();
}
if let Some(content) = snapshot.get("content").and_then(|v| v.as_str()) {
return content.to_string();
}
if let Some(lines) = snapshot.get("lines").and_then(|v| v.as_array()) {
return lines
.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.join("\n");
}
snapshot.to_string()
}