mod managed;
mod session_connect;
#[cfg(test)]
mod tests;
mod types;
pub use types::{
BreakerRow, ChatMessage, ConfigRecommendation, CoordinatorChatOutcome, CoordinatorContext,
CoordinatorSession, DiscoveredProjectRow, EventRow, FleetByProjectWireResponse,
FleetProjectGroupWire, HealthSnapshot, LastSeen, LlmChatOutcome, ManagedActivityResponse,
ManagedAdoptRequest, ManagedAdoptResponse, ManagedAnswerRequest, ManagedAnswerResponse,
ManagedAttachCmdResponse, ManagedListResponse, ManagedSendInputRequest,
ManagedSendInputResponse, ManagedSessionSummary, ManagedSpawnRequest, ManagedSpawnResponse,
OverseerSnapshot, PairConfirm, PairRequest, PairStatus, SessionRow, TmuxSessionRow,
};
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct DaemonClient {
pub(in crate::client::http_client) base: String,
pub(in crate::client::http_client) http: reqwest::Client,
}
impl DaemonClient {
pub fn new(base: impl Into<String>) -> Self {
Self::with_client(reqwest::Client::new(), base)
}
pub fn with_client(client: reqwest::Client, base: impl Into<String>) -> Self {
Self {
base: base.into(),
http: client,
}
}
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 = format!("{}/sessions", self.base);
let body: Body = self.http.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 = format!("{}/events/poll", self.base);
let body: Body = self.http.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", self.base);
let body: Body = self.http.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 = format!("{}/breakers", self.base);
let body: Body = self.http.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 = format!("{}/health", self.base);
matches!(self.http.get(&url).send().await, Ok(r) if r.status().is_success())
}
pub async fn catalog_stale(&self) -> bool {
let url = format!("{}/health", self.base);
let Ok(resp) = self.http.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 = format!("{}/health", self.base);
let snapshot = self
.http
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(snapshot)
}
pub async fn pause_session(&self, id: &str) -> anyhow::Result<String> {
let url = format!("{}/sessions/{id}/pause", self.base);
let body: serde_json::Value = self
.http
.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.base);
self.http.post(&url).send().await?.error_for_status()?;
Ok(())
}
pub async fn kill_session(&self, id: &str) -> anyhow::Result<bool> {
let url = format!("{}/sessions/{id}", self.base);
let resp = self.http.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", self.base);
let body: serde_json::Value = self
.http
.get(&url)
.query(&[("lines", lines.to_string())])
.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", self.base);
let resp = self
.http
.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 = format!("{}/overseer", self.base);
let body: serde_json::Value = self.http.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 = format!("{}/tmux/sessions", self.base);
let body: serde_json::Value = self.http.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 = format!("{}/projects/discover", self.base);
let body: Body = self.http.get(&url).send().await?.json().await?;
Ok(body.projects)
}
pub async fn register_project(&self, path: &str) -> anyhow::Result<()> {
let url = format!("{}/projects", self.base);
self.http
.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", self.base);
let resp = self.http.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 = format!("{}/tmux/adopt", self.base);
let resp = self
.http
.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 = format!("{}/sessions/discover", self.base);
let body: serde_json::Value = self
.http
.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 analyze_config(&self, project: &str) -> anyhow::Result<Vec<ConfigRecommendation>> {
let url = format!("{}/claude-config", self.base);
let body: serde_json::Value = self
.http
.get(&url)
.query(&[("project", project)])
.send()
.await?
.json()
.await?;
let recs = body["recommendations"]
.as_array()
.cloned()
.unwrap_or_default();
Ok(recs
.iter()
.map(|r| ConfigRecommendation {
id: r
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
message: r
.get("message")
.and_then(|v| v.as_str())
.or_else(|| r.as_str())
.unwrap_or("?")
.to_string(),
})
.collect())
}
pub async fn apply_recommendation(
&self,
project: &str,
recommendation_id: &str,
) -> anyhow::Result<String> {
let url = format!("{}/claude-config/apply", self.base);
let body: serde_json::Value = self
.http
.post(&url)
.json(&serde_json::json!({
"project": project,
"recommendation_id": recommendation_id,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body
.get("checkpoint_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn list_checkpoints(&self, project: &str) -> anyhow::Result<Vec<serde_json::Value>> {
let url = format!("{}/claude-config/checkpoints", self.base);
let body: serde_json::Value = self
.http
.get(&url)
.query(&[("project", project)])
.send()
.await?
.json()
.await?;
Ok(body["checkpoints"].as_array().cloned().unwrap_or_default())
}
pub async fn deploy_profile(
&self,
project: &str,
profile_name: &str,
) -> anyhow::Result<String> {
let url = format!("{}/claude-config/deploy", self.base);
let body: serde_json::Value = self
.http
.post(&url)
.json(&serde_json::json!({
"project": project,
"profile_name": profile_name,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body
.get("checkpoint_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
pub async fn pair_request(&self) -> anyhow::Result<PairRequest> {
let url = format!("{}/pair/request", self.base);
let body: PairRequest = self
.http
.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 = format!("{}/pair/confirm", self.base);
let body: PairConfirm = self
.http
.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 = format!("{}/llm/chat", self.base);
let resp = self
.http
.post(&url)
.json(&serde_json::json!({ "message": message, "history": history }))
.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 = format!("{}/api/v1/sessions/context", self.base);
let context = self
.http
.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 = format!("{}/api/v1/sessions/chat", self.base);
let resp = self
.http
.post(&url)
.json(&coordinator_chat_body(message, history, actions))
.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 = format!("{}/pair/status", self.base);
let body: PairStatus = self.http.get(&url).send().await?.json().await?;
Ok(body)
}
pub async fn doctor(
&self,
project: Option<&str>,
) -> anyhow::Result<crate::core::doctor::DoctorReport> {
let url = format!("{}/api/v1/doctor", self.base);
let mut request = self.http.get(&url);
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()
}