use std::sync::Arc;
use crate::core::compress::CompressionLevel;
use crate::core::hook::HookEvent;
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};
use crate::daemon::api::session_start_correlation::{correlate_session_start, handle_session_end};
use crate::daemon::api::types::{
CommandResponse, DiscoverResponse, EventsResponse, HookAcceptedResponse, OutputResponse,
PauseResponse, ReapResponse, RegisterSessionResponse, RemoveSessionResponse, ResumeResponse,
SessionsResponse, SetPidResponse,
};
use crate::daemon::api::{HookPost, RegisterSession, SessionQuery};
use crate::daemon::error::DaemonError;
use crate::daemon::services::{HookDecision, HookService, SessionService, TmuxService};
use crate::daemon::state::DaemonState;
pub fn parse_id(raw: &str) -> Result<SessionId, DaemonError> {
uuid::Uuid::parse_str(raw)
.map(SessionId)
.map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {raw}")))
}
pub struct CompressedOutput {
pub text: String,
pub stats: crate::core::compress::CompressionStats,
pub level_label: Option<String>,
}
pub 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,
},
}
}
pub fn compression_level_label(level: CompressionLevel) -> String {
match level {
CompressionLevel::Off => "off",
CompressionLevel::Trim => "trim",
CompressionLevel::Summarise => "summarise",
CompressionLevel::Caveman => "caveman",
}
.to_string()
}
fn default_output_lines() -> u32 {
50
}
pub fn list_sessions(state: &Arc<DaemonState>, query: SessionQuery) -> SessionsResponse {
let sessions = match query.project {
Some(path) => state.list_sessions_for_project(&path),
None => state.list_sessions(),
};
SessionsResponse { sessions }
}
pub fn register_session(
state: &Arc<DaemonState>,
body: RegisterSession,
) -> Result<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);
crate::daemon::services::session_service::spawn_pid_capture(
Arc::clone(state),
id,
tmux_name.clone(),
);
Ok(RegisterSessionResponse {
id,
name: tmux_name,
})
}
pub fn get_session(state: &Arc<DaemonState>, id: &str) -> Result<Session, DaemonError> {
let session_id = parse_id(id)?;
state
.session(session_id)
.ok_or_else(|| DaemonError::SessionNotFound { id: id.to_string() })
}
pub async fn remove_session(
state: &Arc<DaemonState>,
key: &str,
) -> Result<RemoveSessionResponse, DaemonError> {
let target = SessionService::new(state).resolve(key)?;
let removed = state
.remove_session(target.id)
.ok_or_else(|| DaemonError::SessionNotFound {
id: key.to_string(),
})?;
let tmux_name = removed.tmux_name.clone();
TmuxService::kill_best_effort(&tmux_name);
reconcile_managed_store_on_delete(state, &tmux_name).await;
Ok(RemoveSessionResponse {
removed: key.to_string(),
})
}
async fn reconcile_managed_store_on_delete(state: &Arc<DaemonState>, tmux_name: &str) {
let mgr = state.session_manager().await;
for record in mgr.list().await {
if record.tmux_name != tmux_name {
continue;
}
if matches!(
record.state,
crate::session_manager::ManagedSessionState::Stopped
| crate::session_manager::ManagedSessionState::Decommissioned
) {
continue;
}
if let Err(e) = mgr.decommission(&record.id, None).await {
tracing::warn!(
name = %tmux_name,
"delete reconcile: decommission of managed record failed (may already be gone): {e}"
);
}
}
}
pub fn reap_sessions(state: &Arc<DaemonState>) -> ReapResponse {
let result = SessionService::new(state).reap();
ReapResponse {
removed: result.reaped,
stopped: result.stopped,
}
}
pub fn set_session_pid(
state: &Arc<DaemonState>,
id: &str,
pid: u32,
) -> Result<SetPidResponse, DaemonError> {
let session = parse_id(id)?;
if state.set_session_pid(session, pid) {
Ok(SetPidResponse {
session_id: id.to_string(),
pid,
})
} else {
Err(DaemonError::SessionNotFound { id: id.to_string() })
}
}
pub async fn discover_sessions(state: &Arc<DaemonState>) -> DiscoverResponse {
let result = crate::daemon::discovery::discover_all(state).await;
DiscoverResponse {
discovered: result.adopted,
sessions: result.sessions,
skipped: result.skipped,
}
}
pub fn recent_events(state: &Arc<DaemonState>) -> EventsResponse {
EventsResponse {
events: state.recent_hook_events(),
}
}
pub fn session_events(state: &Arc<DaemonState>, id: &str) -> Result<EventsResponse, DaemonError> {
let session = parse_id(id)?;
Ok(EventsResponse {
events: state.hook_events_for(session),
})
}
pub fn pause_session(
state: &Arc<DaemonState>,
id: &str,
summary: Option<String>,
) -> Result<PauseResponse, DaemonError> {
let result = SessionService::new(state).pause(id, summary)?;
Ok(PauseResponse {
paused: true,
session_id: result.session_id,
summary: result.summary,
})
}
pub fn resume_session(state: &Arc<DaemonState>, id: &str) -> Result<ResumeResponse, DaemonError> {
SessionService::new(state).resume(id)?;
Ok(ResumeResponse { resumed: true })
}
pub async fn send_command(
state: &Arc<DaemonState>,
id: &str,
command: &str,
compress: Option<CompressionLevel>,
) -> Result<CommandResponse, DaemonError> {
let session = SessionService::new(state).command_target(id)?;
TmuxService::send_command(&session, command);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let raw = TmuxService::capture(&session, 100);
let compressed = apply_compression(compress, &raw);
Ok(CommandResponse {
sent: true,
output: compressed.text,
original_bytes: compressed.stats.original_bytes,
compressed_bytes: compressed.stats.compressed_bytes,
compress_level: compressed.level_label,
})
}
pub fn get_output(
state: &Arc<DaemonState>,
id: &str,
lines: Option<u32>,
compress: Option<CompressionLevel>,
) -> Result<OutputResponse, DaemonError> {
let session = SessionService::new(state).resolve(id)?;
let lines = lines.unwrap_or_else(default_output_lines);
let raw = TmuxService::capture(&session, lines);
let compressed = apply_compression(compress, &raw);
Ok(OutputResponse {
output: compressed.text,
lines,
original_bytes: compressed.stats.original_bytes,
compressed_bytes: compressed.stats.compressed_bytes,
compress_level: compressed.level_label,
})
}
pub async fn ingest_hook(
state: &Arc<DaemonState>,
post: HookPost,
) -> Result<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:?}");
}
if post.event == HookEvent::SessionStart {
correlate_session_start(state, &post.session_id, &post.payload).await;
}
if post.event == HookEvent::SessionEnd {
handle_session_end(state, &post.session_id).await;
}
match HookService::new(Arc::clone(state)).process(session, post.event, post.payload) {
HookDecision::Block { reason } => Err(DaemonError::OverseerBlocked { reason }),
_ => Ok(HookAcceptedResponse {
accepted: post.event,
}),
}
}