use std::path::PathBuf;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use trusty_mpm_core::compress::CompressionLevel;
use trusty_mpm_core::hook::HookEvent;
use trusty_mpm_core::project::ProjectInfo;
use trusty_mpm_core::session::{ControlModel, Session, SessionId, SessionStatus};
use crate::error::DaemonError;
use crate::services::{HookDecision, HookService, PairingService, SessionService, TmuxService};
use crate::state::DaemonState;
pub mod claude_config_routes;
pub use claude_config_routes::*;
pub mod types;
pub use types::*;
pub fn router(state: Arc<DaemonState>) -> Router {
Router::new()
.route("/health", get(health))
.route("/sessions", get(list_sessions).post(register_session))
.route("/api/v1/sessions/connect", post(connect_session))
.route("/sessions/dead", axum::routing::delete(reap_sessions))
.route("/sessions/discover", post(discover_sessions))
.route("/sessions/{id}", axum::routing::delete(remove_session))
.route("/sessions/{id}/events", get(session_events))
.route("/sessions/{id}/pause", post(pause_session))
.route("/sessions/{id}/resume", post(resume_session))
.route("/sessions/{id}/command", post(send_command))
.route("/sessions/{id}/output", get(get_output))
.route("/sessions/{id}/pid", axum::routing::patch(set_session_pid))
.route("/projects", get(list_projects).post(register_project))
.route("/projects/current", get(current_project))
.route("/projects/discover", get(discover_projects))
.route("/events", get(recent_events))
.route("/hooks", post(ingest_hook))
.route("/breakers", get(breakers))
.route("/optimizer", get(get_optimizer))
.route("/overseer", get(get_overseer))
.route("/llm/chat", post(llm_chat))
.route("/tmux/sessions", get(list_tmux_sessions))
.route("/tmux/sessions/{name}/snapshot", get(tmux_snapshot))
.route("/tmux/adopt", post(adopt_tmux_session))
.route("/claude-config", get(get_claude_config))
.route("/claude-config/apply", post(apply_claude_config))
.route("/claude-config/restart", post(restart_claude_code))
.route(
"/claude-config/checkpoints",
get(list_checkpoints).post(create_checkpoint),
)
.route(
"/claude-config/checkpoints/{id}",
axum::routing::delete(delete_checkpoint),
)
.route("/claude-config/restore", post(restore_checkpoint))
.route("/claude-config/profiles", get(list_profiles))
.route("/claude-config/deploy", post(deploy_profile))
.route("/pair/request", post(pair_request))
.route("/pair/confirm", post(pair_confirm))
.route("/pair/status", get(pair_status))
.route("/pair/reset", post(pair_reset))
.route("/api/v1/doctor", get(doctor))
.merge(
SwaggerUi::new("/api-docs")
.url("/api-docs/openapi.json", crate::openapi::ApiDoc::openapi()),
)
.with_state(state)
}
#[utoipa::path(
get,
path = "/health",
tag = "config",
responses((status = 200, description = "Daemon is alive", body = String))
)]
pub async fn health() -> &'static str {
"ok"
}
#[derive(serde::Deserialize, Default)]
pub struct SessionQuery {
pub project: Option<PathBuf>,
}
#[utoipa::path(
get,
path = "/sessions",
tag = "sessions",
params(("project" = Option<String>, Query, description = "Filter by project path")),
responses((status = 200, description = "Array of managed sessions", body = [Session]))
)]
pub async fn list_sessions(
State(state): State<Arc<DaemonState>>,
Query(query): Query<SessionQuery>,
) -> Json<SessionsResponse> {
let sessions = match query.project {
Some(path) => state.list_sessions_for_project(&path),
None => state.list_sessions(),
};
Json(SessionsResponse { sessions })
}
#[utoipa::path(
get,
path = "/events",
tag = "events",
responses((status = 200, description = "Recent hook events across all sessions"))
)]
pub async fn recent_events(State(state): State<Arc<DaemonState>>) -> Json<EventsResponse> {
Json(EventsResponse {
events: state.recent_hook_events(),
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterSession {
pub project: String,
#[serde(default)]
#[schema(value_type = Option<String>)]
pub project_path: Option<PathBuf>,
#[serde(default)]
pub name: Option<String>,
}
#[utoipa::path(
post,
path = "/sessions",
tag = "sessions",
request_body = RegisterSession,
responses((status = 201, description = "Session registered; returns its id and name"))
)]
pub async fn register_session(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RegisterSession>,
) -> Json<RegisterSessionResponse> {
let project_dir = body.project_path.as_deref();
let mut session = Session::new(
SessionId::new(),
body.project.clone(),
ControlModel::Tmux,
project_dir,
);
session.project_path = body.project_path.clone();
if let Some(name) = body.name.as_deref().filter(|n| !n.is_empty()) {
session.tmux_name = name.to_string();
}
let id = session.id;
let tmux_name = session.tmux_name.clone();
state.register_session(session);
crate::services::session_service::spawn_pid_capture(Arc::clone(&state), id, tmux_name.clone());
Json(RegisterSessionResponse {
id,
name: tmux_name,
})
}
#[utoipa::path(
post,
path = "/api/v1/sessions/connect",
tag = "sessions",
request_body = RegisterSession,
responses((status = 201, description = "Session registered for connect; returns its id and name"))
)]
pub async fn connect_session(
state: State<Arc<DaemonState>>,
body: Json<RegisterSession>,
) -> Json<RegisterSessionResponse> {
register_session(state, body).await
}
#[utoipa::path(
delete,
path = "/sessions/{id}",
tag = "sessions",
params(("id" = String, Path, description = "Session UUID")),
responses(
(status = 200, description = "Session removed"),
(status = 404, description = "No session with that id"),
)
)]
pub async fn remove_session(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
) -> Result<Json<RemoveSessionResponse>, DaemonError> {
let session = parse_id(&id)?;
match state.remove_session(session) {
Some(_) => Ok(Json(RemoveSessionResponse { removed: id })),
None => Err(DaemonError::SessionNotFound { id }),
}
}
#[utoipa::path(
delete,
path = "/sessions/dead",
tag = "sessions",
responses((status = 200, description = "Dead sessions reaped; returns the removed count"))
)]
pub async fn reap_sessions(State(state): State<Arc<DaemonState>>) -> Json<ReapResponse> {
let result = SessionService::new(&state).reap();
Json(ReapResponse {
removed: result.reaped,
stopped: result.stopped,
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct SetPidRequest {
pub pid: u32,
}
#[utoipa::path(
patch,
path = "/sessions/{id}/pid",
tag = "sessions",
params(("id" = String, Path, description = "Session UUID")),
request_body = SetPidRequest,
responses(
(status = 200, description = "PID recorded for the session"),
(status = 404, description = "No session with that id"),
)
)]
pub async fn set_session_pid(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Json(body): Json<SetPidRequest>,
) -> Result<Json<SetPidResponse>, DaemonError> {
let session = parse_id(&id)?;
if state.set_session_pid(session, body.pid) {
Ok(Json(SetPidResponse {
session_id: id,
pid: body.pid,
}))
} else {
Err(DaemonError::SessionNotFound { id })
}
}
#[utoipa::path(
post,
path = "/sessions/discover",
tag = "sessions",
responses((status = 200, description = "tmux sessions running Claude Code, newly registered"))
)]
pub async fn discover_sessions(State(state): State<Arc<DaemonState>>) -> Json<DiscoverResponse> {
let result = crate::discovery::discover_all(&state);
Json(DiscoverResponse {
discovered: result.adopted,
sessions: result.sessions,
})
}
#[utoipa::path(
get,
path = "/sessions/{id}/events",
tag = "events",
params(("id" = String, Path, description = "Session UUID")),
responses(
(status = 200, description = "Recent hook events for the session"),
(status = 404, description = "No session with that id"),
)
)]
pub async fn session_events(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
) -> Result<Json<EventsResponse>, DaemonError> {
let session = parse_id(&id)?;
Ok(Json(EventsResponse {
events: state.hook_events_for(session),
}))
}
struct CompressedOutput {
text: String,
stats: trusty_mpm_core::compress::CompressionStats,
level_label: Option<String>,
}
fn apply_compression(level: Option<CompressionLevel>, raw: &str) -> CompressedOutput {
match level {
Some(level) => {
let (text, stats) = trusty_mpm_core::compress::compress_output(raw, level);
CompressedOutput {
text,
stats,
level_label: Some(compression_level_label(level)),
}
}
None => CompressedOutput {
text: raw.to_string(),
stats: trusty_mpm_core::compress::CompressionStats::default(),
level_label: None,
},
}
}
fn compression_level_label(level: CompressionLevel) -> String {
match level {
CompressionLevel::Off => "off",
CompressionLevel::Trim => "trim",
CompressionLevel::Summarise => "summarise",
CompressionLevel::Caveman => "caveman",
}
.to_string()
}
#[derive(serde::Deserialize, utoipa::ToSchema, Default)]
pub struct PauseRequest {
#[serde(default)]
pub summary: Option<String>,
}
#[utoipa::path(
post,
path = "/sessions/{id}/pause",
tag = "sessions",
params(("id" = String, Path, description = "Session UUID or friendly name")),
request_body = PauseRequest,
responses(
(status = 200, description = "Session paused; returns the pause summary"),
(status = 404, description = "No session with that id or name"),
)
)]
pub async fn pause_session(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Json(body): Json<PauseRequest>,
) -> Result<Json<PauseResponse>, DaemonError> {
let result = SessionService::new(&state).pause(&id, body.summary)?;
Ok(Json(PauseResponse {
paused: true,
session_id: result.session_id,
summary: result.summary,
}))
}
#[utoipa::path(
post,
path = "/sessions/{id}/resume",
tag = "sessions",
params(("id" = String, Path, description = "Session UUID or friendly name")),
responses(
(status = 200, description = "Session resumed"),
(status = 404, description = "No session with that id or name"),
(status = 409, description = "Session is not paused"),
)
)]
pub async fn resume_session(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
) -> Result<Json<ResumeResponse>, DaemonError> {
SessionService::new(&state).resume(&id)?;
Ok(Json(ResumeResponse { resumed: true }))
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct CommandRequest {
pub command: String,
}
#[derive(serde::Deserialize, Default)]
pub struct CommandQuery {
#[serde(default)]
pub compress: Option<CompressionLevel>,
}
#[utoipa::path(
post,
path = "/sessions/{id}/command",
tag = "sessions",
params(
("id" = String, Path, description = "Session UUID or friendly name"),
("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
),
request_body = CommandRequest,
responses(
(status = 200, description = "Command sent; returns captured pane output"),
(status = 404, description = "No session with that id or name"),
(status = 409, description = "Session is stopped"),
)
)]
pub async fn send_command(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Query(query): Query<CommandQuery>,
Json(body): Json<CommandRequest>,
) -> Result<Json<CommandResponse>, DaemonError> {
let session = SessionService::new(&state).command_target(&id)?;
TmuxService::send_command(&session, &body.command);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let raw = TmuxService::capture(&session, 100);
let compressed = apply_compression(query.compress, &raw);
Ok(Json(CommandResponse {
sent: true,
output: compressed.text,
original_bytes: compressed.stats.original_bytes,
compressed_bytes: compressed.stats.compressed_bytes,
compress_level: compressed.level_label,
}))
}
#[derive(serde::Deserialize, Default)]
pub struct OutputQuery {
#[serde(default)]
pub lines: Option<u32>,
#[serde(default)]
pub compress: Option<CompressionLevel>,
}
fn default_output_lines() -> u32 {
50
}
#[utoipa::path(
get,
path = "/sessions/{id}/output",
tag = "sessions",
params(
("id" = String, Path, description = "Session UUID or friendly name"),
("lines" = Option<u32>, Query, description = "Trailing lines to capture (default 50)"),
("compress" = Option<String>, Query, description = "Compression level: off, trim, summarise, caveman"),
),
responses(
(status = 200, description = "Captured pane output"),
(status = 404, description = "No session with that id or name"),
)
)]
pub async fn get_output(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Query(query): Query<OutputQuery>,
) -> Result<Json<OutputResponse>, DaemonError> {
let session = SessionService::new(&state).resolve(&id)?;
let lines = query.lines.unwrap_or_else(default_output_lines);
let raw = TmuxService::capture(&session, lines);
let compressed = apply_compression(query.compress, &raw);
Ok(Json(OutputResponse {
output: compressed.text,
lines,
original_bytes: compressed.stats.original_bytes,
compressed_bytes: compressed.stats.compressed_bytes,
compress_level: compressed.level_label,
}))
}
#[utoipa::path(
get,
path = "/breakers",
tag = "config",
responses((status = 200, description = "Array of per-agent circuit-breaker states"))
)]
pub async fn breakers(State(state): State<Arc<DaemonState>>) -> Json<BreakersResponse> {
let breakers = state
.all_breakers()
.into_iter()
.map(|(agent, breaker)| BreakerEntry { agent, breaker })
.collect();
Json(BreakersResponse { breakers })
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct HookPost {
pub session_id: String,
#[schema(value_type = String)]
pub event: HookEvent,
#[serde(default)]
#[schema(value_type = Object)]
pub payload: serde_json::Value,
}
#[utoipa::path(
post,
path = "/hooks",
tag = "internal",
request_body = HookPost,
responses(
(status = 200, description = "Hook event accepted"),
(status = 400, description = "Unknown event name or malformed session id"),
(status = 403, description = "Overseer blocked the event"),
)
)]
pub async fn ingest_hook(
State(state): State<Arc<DaemonState>>,
Json(post): Json<HookPost>,
) -> Result<Json<HookAcceptedResponse>, DaemonError> {
let session = parse_id(&post.session_id)?;
if post.event == HookEvent::SessionStart && state.session(session).is_none() {
let mut new_session = Session::new(session, String::new(), ControlModel::Tmux, None);
new_session.status = SessionStatus::Active;
state.register_session(new_session);
tracing::info!("auto-registered session on SessionStart: {session:?}");
}
match HookService::new(&state).process(session, post.event, post.payload) {
HookDecision::Block { reason } => Err(DaemonError::OverseerBlocked { reason }),
_ => Ok(Json(HookAcceptedResponse {
accepted: post.event,
})),
}
}
#[utoipa::path(
get,
path = "/overseer",
tag = "config",
responses((status = 200, description = "Overseer enabled flag and handler type"))
)]
pub async fn get_overseer(State(state): State<Arc<DaemonState>>) -> Json<OverseerResponse> {
Json(OverseerResponse {
overseer: OverseerStatus {
enabled: state.overseer().is_enabled(),
handler: state.overseer_handler().to_string(),
},
})
}
#[utoipa::path(
post,
path = "/llm/chat",
tag = "config",
request_body = LlmChatRequest,
responses(
(status = 200, description = "Assistant reply and updated history"),
(status = 503, description = "LLM chat is not configured on this daemon"),
)
)]
pub async fn llm_chat(
State(state): State<Arc<DaemonState>>,
Json(body): Json<LlmChatRequest>,
) -> Result<Json<LlmChatResponse>, DaemonError> {
let overseer = state.llm_overseer().ok_or_else(|| {
DaemonError::ServiceUnavailable(
"LLM chat is not configured (no OpenRouter API key)".to_string(),
)
})?;
let mut history = body.history;
let reply = overseer
.chat(&mut history, &body.message)
.await
.map_err(|e| DaemonError::Internal(e.to_string()))?;
Ok(Json(LlmChatResponse { reply, history }))
}
#[utoipa::path(
get,
path = "/optimizer",
tag = "config",
responses((status = 200, description = "Current token-use optimizer configuration"))
)]
pub async fn get_optimizer(State(state): State<Arc<DaemonState>>) -> Json<OptimizerResponse> {
Json(OptimizerResponse {
optimizer: state.optimizer_config(),
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterProject {
#[schema(value_type = String)]
pub path: PathBuf,
}
#[utoipa::path(
post,
path = "/projects",
tag = "projects",
request_body = RegisterProject,
responses((status = 201, description = "Project registered", body = ProjectInfo))
)]
pub async fn register_project(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RegisterProject>,
) -> Json<ProjectInfo> {
Json(state.register_project(body.path))
}
#[utoipa::path(
get,
path = "/projects",
tag = "projects",
responses((status = 200, description = "Array of registered projects", body = [ProjectInfo]))
)]
pub async fn list_projects(State(state): State<Arc<DaemonState>>) -> Json<ProjectsResponse> {
Json(ProjectsResponse {
projects: state.list_projects(),
})
}
#[derive(serde::Deserialize)]
pub struct CurrentProjectQuery {
pub path: PathBuf,
}
#[utoipa::path(
get,
path = "/projects/current",
tag = "projects",
params(("path" = String, Query, description = "Directory whose project to resolve")),
responses(
(status = 200, description = "The project registered for the path", body = ProjectInfo),
(status = 404, description = "Path is not a registered project"),
)
)]
pub async fn current_project(
State(state): State<Arc<DaemonState>>,
Query(query): Query<CurrentProjectQuery>,
) -> Result<Json<ProjectInfo>, DaemonError> {
match state.project(&query.path) {
Some(info) => Ok(Json(info)),
None => Err(DaemonError::SessionNotFound {
id: query.path.display().to_string(),
}),
}
}
#[utoipa::path(
get,
path = "/projects/discover",
tag = "projects",
responses((status = 200, description = "Projects discovered from Claude Code config"))
)]
pub async fn discover_projects(
State(_state): State<Arc<DaemonState>>,
) -> Json<DiscoverProjectsResponse> {
let projects = trusty_mpm_core::project_discovery::ProjectDiscovery::discover()
.into_iter()
.map(|p| DiscoveredProjectInfo {
path: p.path.display().to_string(),
session_count: p.session_count,
last_session: p.last_session.map(system_time_to_iso8601),
})
.collect();
Json(DiscoverProjectsResponse { projects })
}
fn system_time_to_iso8601(time: std::time::SystemTime) -> String {
let datetime: chrono::DateTime<chrono::Utc> = time.into();
datetime.to_rfc3339()
}
#[utoipa::path(
get,
path = "/tmux/sessions",
tag = "tmux",
responses((status = 200, description = "All tmux sessions with origin labels"))
)]
pub async fn list_tmux_sessions(
State(_state): State<Arc<DaemonState>>,
) -> Json<TmuxSessionsResponse> {
Json(TmuxSessionsResponse {
sessions: TmuxService::list_all(),
})
}
#[utoipa::path(
get,
path = "/tmux/sessions/{name}/snapshot",
tag = "tmux",
params(("name" = String, Path, description = "tmux session name")),
responses(
(status = 200, description = "Session snapshot"),
(status = 404, description = "Session not found or tmux unavailable"),
)
)]
pub async fn tmux_snapshot(
State(_state): State<Arc<DaemonState>>,
Path(name): Path<String>,
) -> Result<Json<TmuxSnapshotResponse>, DaemonError> {
let snapshot = TmuxService::snapshot(&name, 100)?;
Ok(Json(TmuxSnapshotResponse { snapshot }))
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct AdoptRequest {
pub session: String,
}
#[utoipa::path(
post,
path = "/tmux/adopt",
tag = "tmux",
request_body = AdoptRequest,
responses(
(status = 200, description = "Session adopted; returns its captured state"),
(status = 404, description = "Session not found or tmux unavailable"),
)
)]
pub async fn adopt_tmux_session(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<AdoptRequest>,
) -> Result<Json<AdoptResponse>, DaemonError> {
let adopted = TmuxService::adopt(&body.session)?;
Ok(Json(AdoptResponse { adopted }))
}
#[utoipa::path(
post,
path = "/pair/request",
tag = "config",
responses((status = 200, description = "A one-time pairing code and its TTL"))
)]
pub async fn pair_request(
State(state): State<Arc<DaemonState>>,
) -> Json<crate::services::PairCode> {
Json(PairingService::new(&state).request_code())
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct PairConfirmRequest {
pub code: String,
pub chat_id: i64,
}
#[utoipa::path(
post,
path = "/pair/confirm",
tag = "config",
request_body = PairConfirmRequest,
responses((status = 200, description = "Pairing result (success flag and chat id or error)"))
)]
pub async fn pair_confirm(
State(state): State<Arc<DaemonState>>,
Json(body): Json<PairConfirmRequest>,
) -> Json<PairConfirmResponse> {
match PairingService::new(&state).confirm(&body.code, body.chat_id) {
Ok(()) => Json(PairConfirmResponse {
success: true,
chat_id: Some(body.chat_id),
error: None,
}),
Err(_) => Json(PairConfirmResponse {
success: false,
chat_id: None,
error: Some("invalid or expired code".to_string()),
}),
}
}
#[utoipa::path(
get,
path = "/pair/status",
tag = "config",
responses((status = 200, description = "Pairing status (paired flag and chat id)"))
)]
pub async fn pair_status(
State(state): State<Arc<DaemonState>>,
) -> Json<crate::services::PairStatus> {
Json(PairingService::new(&state).status())
}
#[utoipa::path(
post,
path = "/pair/reset",
tag = "config",
responses((status = 200, description = "Pairing cleared"))
)]
pub async fn pair_reset(State(state): State<Arc<DaemonState>>) -> Json<PairResetResponse> {
PairingService::new(&state).reset();
Json(PairResetResponse { reset: true })
}
#[derive(serde::Deserialize, Default)]
pub struct DoctorQuery {
pub project: Option<PathBuf>,
}
#[utoipa::path(
get,
path = "/api/v1/doctor",
tag = "config",
params(("project" = Option<String>, Query, description = "Project directory to scope the instruction probe to")),
responses((status = 200, description = "Full diagnostic report"))
)]
pub async fn doctor(
State(_state): State<Arc<DaemonState>>,
Query(query): Query<DoctorQuery>,
) -> Json<trusty_mpm_core::doctor::DoctorReport> {
Json(crate::doctor::run_doctor(query.project.as_deref()).await)
}
fn parse_id(raw: &str) -> Result<SessionId, DaemonError> {
uuid::Uuid::parse_str(raw)
.map(SessionId)
.map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {raw}")))
}
#[cfg(test)]
#[path = "api_tests.rs"]
mod tests;