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;
}
let policy = crate::session_manager::decommission_force::ProvisioningDirt::Refuse;
match mgr.decommission_reporting(&record.id, None, policy).await {
Ok(report) => {
if let Some(reason) = report.workspace_kept_reason {
tracing::warn!(
name = %tmux_name,
"delete reconcile: decommissioned the managed record but kept its \
workspace: {reason}"
);
}
}
Err(e) => 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 let Some(why) = state.session_claudes().sealed() {
return Err(DaemonError::InvalidRequest(format!(
"session {id}: the session-claude registry is sealed, so no claude pid is \
caller-settable until it is fixed and the daemon restarts: {why} (#8980)"
)));
}
let announced = state.session_claudes().is_settled(session);
if announced || state.all_delegations().iter().any(|d| d.session == session) {
return Err(DaemonError::InvalidRequest(format!(
"session {id} was announced by a SessionStart hook or owns delegation records; its \
claude pid is not caller-settable, since a pid that reads as exited ends the \
session's delegations (#8980)"
)));
}
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,
input_box: TmuxService::input_box(&session),
})
}
pub async fn ingest_hook_from_socket(
state: &Arc<DaemonState>,
post: HookPost,
peer: Option<trusty_common::uds::server::RequestPeer>,
) -> Result<HookAcceptedResponse, DaemonError> {
use crate::daemon::services::delegation_repair_caller::bind_announcing_claude;
let bind = |held: Arc<DaemonState>, session, peer| async move {
bind_announcing_claude(&held, session, peer).await
};
ingest_hook_from_socket_with(state, post, peer, bind).await
}
pub(crate) async fn ingest_hook_from_socket_with<B, F>(
state: &Arc<DaemonState>,
post: HookPost,
peer: Option<trusty_common::uds::server::RequestPeer>,
bind: B,
) -> Result<HookAcceptedResponse, DaemonError>
where
B: FnOnce(
Arc<DaemonState>,
SessionId,
crate::daemon::services::delegation_repair_caller::RepairPeer,
) -> F,
F: std::future::Future<Output = Result<(), String>>,
{
use crate::daemon::services::delegation_repair_caller::{
RepairPeer, stale_on_owner_session_end,
};
let start = (post.event == HookEvent::SessionStart)
.then(|| parse_id(&post.session_id).ok())
.flatten();
let _in_flight = start.map(|session| state.session_claudes().begin_bind(session));
if let Some(session) = start
&& let Err(e) = bind(Arc::clone(state), session, RepairPeer::from_socket(peer)).await
{
tracing::warn!(session_id = %session.0, "SessionStart left the session unbound: {e}");
}
if post.event == HookEvent::SessionEnd
&& let Ok(session) = parse_id(&post.session_id)
&& let Err(e) =
stale_on_owner_session_end(state, session, RepairPeer::from_socket(peer)).await
{
tracing::warn!(
session_id = %session.0,
"SessionEnd left the session's live delegations as they were (#8980): {e}"
);
}
ingest_hook(state, post).await
}
pub async fn ingest_hook(
state: &Arc<DaemonState>,
post: HookPost,
) -> Result<HookAcceptedResponse, DaemonError> {
let session = parse_id(&post.session_id)?;
state.session_claudes().note_event(session);
if post.event == HookEvent::SessionStart {
let held = Arc::clone(state);
let settled = tokio::task::spawn_blocking(move || held.settle_unproven_session(session))
.await
.map_err(|e| e.to_string())
.and_then(|r| r);
if let Err(e) = settled {
tracing::warn!(session_id = %session.0, "SessionStart not recorded as unproven: {e}");
}
}
if post.event.follows_session_start() && !state.session_claudes().is_settled(session) {
let held = Arc::clone(state);
let settled =
tokio::task::spawn_blocking(move || held.session_claudes().settle_after_event(session))
.await
.map_err(|e| e.to_string())
.and_then(|r| r);
match settled {
Ok(true) => tracing::warn!(
session_id = %session.0,
event = ?post.event,
"no SessionStart was seen for this session; it is settled as unproven (#8984)"
),
Ok(false) => {}
Err(e) => tracing::warn!(
session_id = %session.0,
"the session could not be recorded as unproven (#8984): {e}"
),
}
}
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,
}),
}
}