use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::{
Json, Router,
extract::{Path, Query, State},
response::sse::{Event, KeepAlive, Sse},
routing::{get, post},
};
use futures::Stream;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::core::compress::CompressionLevel;
use crate::core::hook::HookEvent;
use crate::core::project::ProjectInfo;
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};
use super::error::DaemonError;
use super::services::{HookDecision, HookService, PairingService, SessionService, TmuxService};
use super::state::DaemonState;
pub mod claude_config_routes;
pub use claude_config_routes::*;
pub mod coordinator_routes;
pub use coordinator_routes::*;
pub mod rpc;
pub use rpc::rpc_handler;
use super::managed_routes::{
answer_session_decision, decommission_managed_session, get_attach_cmd, get_managed_session,
get_session_activity, list_managed_sessions, resume_managed_session, send_to_session,
spawn_session, stop_managed_session, stop_managed_session_runtime,
};
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}", get(get_session).delete(remove_session))
.route("/sessions/{id}/events", get(stream_session_events))
.route("/sessions/{id}/events/poll", 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}/pane", 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(stream_events))
.route("/events/poll", 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("/api/v1/coordinator/context", get(coordinator_context))
.route("/api/v1/coordinator/chat", post(coordinator_chat))
.route("/api/v1/session-manager/context", get(coordinator_context))
.route("/api/v1/session-manager/chat", post(coordinator_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))
.route("/api/v1/errors", get(list_errors))
.route("/api/v1/report-bug", post(report_bug_http))
.route(
"/api/v1/sessions/managed",
post(spawn_session).get(list_managed_sessions),
)
.route(
"/api/v1/sessions/managed/{id}",
get(get_managed_session).delete(stop_managed_session),
)
.route("/api/v1/sessions/managed/{id}/send", post(send_to_session))
.route(
"/api/v1/sessions/managed/{id}/answer",
post(answer_session_decision),
)
.route(
"/api/v1/sessions/managed/{id}/attach-cmd",
get(get_attach_cmd),
)
.route(
"/api/v1/sessions/managed/{id}/activity",
get(get_session_activity),
)
.route(
"/api/v1/sessions/managed/{id}/runtime-stop",
post(stop_managed_session_runtime),
)
.route(
"/api/v1/sessions/managed/{id}/resume",
post(resume_managed_session),
)
.route(
"/api/v1/sessions/managed/{id}/decommission",
post(decommission_managed_session),
)
.route("/rpc", post(rpc_handler))
.merge(
SwaggerUi::new("/api-docs")
.url("/api-docs/openapi.json", super::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/poll",
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(),
})
}
pub async fn stream_events(
State(state): State<Arc<DaemonState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.event_subscribe();
let stream = BroadcastStream::new(rx).filter_map(|result| match result {
Ok(val) => Some(Ok(Event::default().data(val.to_string()))),
Err(_) => None,
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("ping"),
)
}
#[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>,
#[serde(default)]
#[schema(value_type = Option<String>)]
pub workdir: Option<PathBuf>,
}
#[utoipa::path(
post,
path = "/sessions",
tag = "sessions",
request_body = RegisterSession,
responses(
(status = 201, description = "Session registered; returns its id and name"),
(status = 422, description = "Spawn requested but `claude` binary or tmux is unavailable"),
(status = 500, description = "tmux command failed while creating the session"),
)
)]
pub async fn register_session(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RegisterSession>,
) -> Result<Json<RegisterSessionResponse>, DaemonError> {
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();
}
if let Some(workdir) = body.workdir.as_deref() {
session.workdir = workdir.to_string_lossy().into_owned();
}
if let Some(workdir) = body.workdir.as_deref() {
TmuxService::spawn_claude(&session.tmux_name, workdir)?;
session.status = SessionStatus::Active;
}
let id = session.id;
let tmux_name = session.tmux_name.clone();
state.register_session(session);
super::services::session_service::spawn_pid_capture(Arc::clone(&state), id, tmux_name.clone());
Ok(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>,
) -> Result<Json<RegisterSessionResponse>, DaemonError> {
register_session(state, body).await
}
#[utoipa::path(
get,
path = "/sessions/{id}",
tag = "sessions",
params(("id" = String, Path, description = "Session UUID")),
responses(
(status = 200, description = "Session detail", body = Session),
(status = 400, description = "Malformed session id"),
(status = 404, description = "No session with that id"),
)
)]
pub async fn get_session(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
) -> Result<Json<Session>, DaemonError> {
let session_id = parse_id(&id)?;
state
.session(session_id)
.map(Json)
.ok_or(DaemonError::SessionNotFound { id })
}
#[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 = super::discovery::discover_all(&state);
Json(DiscoverResponse {
discovered: result.adopted,
sessions: result.sessions,
})
}
#[utoipa::path(
get,
path = "/sessions/{id}/events/poll",
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),
}))
}
pub async fn stream_session_events(
Path(id): Path<String>,
State(state): State<Arc<DaemonState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.event_subscribe();
let stream = BroadcastStream::new(rx).filter_map(move |result| match result {
Ok(val) => {
if val.to_string().contains(&id) {
Some(Ok(Event::default().data(val.to_string())))
} else {
None
}
}
Err(_) => None,
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("ping"),
)
}
struct CompressedOutput {
text: String,
stats: crate::core::compress::CompressionStats,
level_label: Option<String>,
}
fn apply_compression(level: Option<CompressionLevel>, raw: &str) -> CompressedOutput {
match level {
Some(level) => {
let (text, stats) = crate::core::compress::compress_output(raw, level);
CompressedOutput {
text,
stats,
level_label: Some(compression_level_label(level)),
}
}
None => CompressedOutput {
text: raw.to_string(),
stats: crate::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 = crate::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<super::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<super::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<crate::core::doctor::DoctorReport> {
Json(super::doctor::run_doctor(query.project.as_deref()).await)
}
#[derive(serde::Deserialize, Default)]
pub struct ErrorsQuery {
#[serde(default)]
pub limit: Option<u64>,
}
#[utoipa::path(
get,
path = "/api/v1/errors",
tag = "bug-reporting",
params(("limit" = Option<u64>, Query, description = "Max errors to return (default 20, max 100)")),
responses((status = 200, description = "Deduplicated error list"))
)]
pub async fn list_errors(
State(_state): State<Arc<DaemonState>>,
Query(query): Query<ErrorsQuery>,
) -> Json<ErrorsResponse> {
let limit = query.limit.unwrap_or(20).min(100) as usize;
let errors = super::bug_report::aggregate_errors(limit);
let summaries: Vec<ErrorSummary> = errors
.iter()
.map(|e| ErrorSummary {
fingerprint: e.record.fingerprint.clone(),
crate_target: e.record.crate_target.clone(),
crate_version: e.record.crate_version.clone(),
summary: e.record.summary(),
occurrences: e.occurrences,
timestamp_secs: e.record.timestamp_secs,
})
.collect();
let total = summaries.len();
Json(ErrorsResponse {
errors: summaries,
total,
limit,
})
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct ReportBugApiRequest {
pub fingerprint: String,
#[serde(default)]
pub confirm: bool,
}
fn to_wire_preview(p: &super::bug_report::IssuePreview) -> BugReportPreview {
BugReportPreview {
title: p.title.clone(),
body: p.body.clone(),
labels: p.labels.clone(),
scrub_changes: p
.scrub_changes
.iter()
.map(|c| ScrubChangeSummary {
pattern: c.pattern.to_string(),
hint: c.hint.to_string(),
})
.collect(),
}
}
#[utoipa::path(
post,
path = "/api/v1/report-bug",
tag = "bug-reporting",
request_body = ReportBugApiRequest,
responses(
(status = 200, description = "Filing result or graceful failure; always 200"),
)
)]
pub async fn report_bug_http(
State(_state): State<Arc<DaemonState>>,
Json(body): Json<ReportBugApiRequest>,
) -> Json<ReportBugHttpResponse> {
let errors = super::bug_report::aggregate_errors(500);
let found = errors
.into_iter()
.find(|e| e.record.fingerprint == body.fingerprint);
let Some(agg) = found else {
return Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some(format!(
"fingerprint `{}` not found in local error stores; \
run GET /api/v1/errors to see available fingerprints",
body.fingerprint
)),
preview: None,
rate_limited: None,
});
};
let preview = super::bug_report::build_preview(&agg);
if !body.confirm {
return Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some("confirm:false — preview only. POST with confirm:true to file.".to_string()),
preview: Some(to_wire_preview(&preview)),
rate_limited: None,
});
}
let guard = super::bug_report::RateLimitGuard::production();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let rl_decision = guard.check(&body.fingerprint, now_secs);
if !rl_decision.is_allowed() {
return Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some(rl_decision.block_reason()),
preview: None,
rate_limited: Some(true),
});
}
let fingerprint = body.fingerprint.clone();
let provider = super::bug_report::ResolvedProvider;
let result =
tokio::task::spawn_blocking(move || super::bug_report::file_issue(&preview, &provider))
.await;
match result {
Ok(Ok(filing)) => {
guard.record_filed(&fingerprint, now_secs);
Json(ReportBugHttpResponse {
filed: true,
deduped: Some(filing.deduped),
issue_url: Some(filing.issue_url),
issue_number: Some(filing.issue_number),
note: None,
preview: None,
rate_limited: None,
})
}
Ok(Err(super::bug_report::GithubFilingError::NoToken)) => Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some(super::bug_report::GithubFilingError::NoToken.to_string()),
preview: None,
rate_limited: None,
}),
Ok(Err(e)) => Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some(format!("GitHub filing failed: {e}")),
preview: None,
rate_limited: None,
}),
Err(e) => Json(ReportBugHttpResponse {
filed: false,
deduped: None,
issue_url: None,
issue_number: None,
note: Some(format!("internal error: {e}")),
preview: None,
rate_limited: None,
}),
}
}
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;
#[cfg(test)]
#[path = "coordinator_sm_tests.rs"]
mod coordinator_sm_tests;