use super::command::{TrustyCommand, help_text};
use super::http_client::{DaemonClient, SessionRow};
pub const MAX_OUTPUT_CHARS: usize = 4000;
use super::result::{
CommandResult, DecisionCounts, DiscoveredProjectSummary, RecommendationSummary, SessionSummary,
TmuxSessionSummary,
};
#[cfg(test)]
mod tests;
pub struct CommandExecutor {
client: DaemonClient,
}
impl CommandExecutor {
pub fn new(daemon_url: impl Into<String>) -> Self {
Self {
client: DaemonClient::new(daemon_url),
}
}
pub fn client(&self) -> &DaemonClient {
&self.client
}
pub async fn execute(&self, cmd: TrustyCommand) -> CommandResult {
match cmd {
TrustyCommand::Help => CommandResult::Help(help_text().to_string()),
TrustyCommand::Alerts => CommandResult::AlertSubscriptions(vec![
"Categories: Permission, Agent".to_string(),
"Memory alerts: enabled".to_string(),
]),
TrustyCommand::Sessions => self.sessions().await,
TrustyCommand::Status { session_id } => self.status(&session_id).await,
TrustyCommand::Approve { session_id } => self.decide(&session_id, true).await,
TrustyCommand::Deny { session_id } => self.decide(&session_id, false).await,
TrustyCommand::Overseer => self.overseer().await,
TrustyCommand::Tmux => self.tmux().await,
TrustyCommand::Projects => self.projects().await,
TrustyCommand::Discover => self.discover().await,
TrustyCommand::Adopt { session } => self.adopt(&session).await,
TrustyCommand::Config { project } => self.config(&project).await,
TrustyCommand::Snapshot { session } => self.snapshot(&session).await,
TrustyCommand::Kill { session_id } => self.kill(&session_id).await,
TrustyCommand::Send { session, prompt } => self.send(&session, &prompt).await,
TrustyCommand::Launch { project, .. } => self.launch(&project).await,
TrustyCommand::Connect { project, .. } => self.connect(&project).await,
TrustyCommand::Start => self.pair_state().await,
TrustyCommand::Doctor => self.doctor().await,
TrustyCommand::CoordinatorChat { message } => self.coordinator_chat(&message).await,
TrustyCommand::Pair { code: None } => self.pair_state().await,
TrustyCommand::Pair { code: Some(_) } => {
self.pair_state().await
}
}
}
pub async fn pair_confirm(&self, code: &str, chat_id: i64) -> CommandResult {
match self.client.pair_confirm(code, chat_id).await {
Ok(confirm) if confirm.success => CommandResult::PairSuccess {
chat_info: format!("chat {}", confirm.chat_id.unwrap_or(chat_id)),
},
Ok(confirm) => CommandResult::Error(
confirm
.error
.unwrap_or_else(|| "invalid or expired code".to_string()),
),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
pub async fn pair_request(&self) -> CommandResult {
match self.client.pair_request().await {
Ok(req) => CommandResult::PairCode {
code: req.code,
expires_in_seconds: req.expires_in_seconds,
},
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn sessions(&self) -> CommandResult {
match self.client.sessions().await {
Ok(rows) => CommandResult::Sessions(
rows.into_iter()
.map(|s| SessionSummary {
id: s.id.0.to_string(),
status: status_label(s.status),
workdir: s.workdir,
})
.collect(),
),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn status(&self, session_id: &str) -> CommandResult {
match self.client.session_events(session_id).await {
Ok(events) => {
let names: Vec<String> = events
.iter()
.rev()
.take(5)
.rev()
.map(|e| e.event.wire_name().to_string())
.collect();
CommandResult::SessionDetail {
id: session_id.to_string(),
status: "active".to_string(),
events: names,
}
}
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn decide(&self, session_id: &str, approved: bool) -> CommandResult {
let exists = match self.client.sessions().await {
Ok(rows) => rows.iter().any(|s| s.id.0.to_string() == session_id),
Err(e) => return CommandResult::Error(format!("daemon unreachable: {e}")),
};
if !exists {
return CommandResult::Error(format!("session {session_id} not found"));
}
let hook_url = format!("{}/hooks", self.client.base_url());
let _ = reqwest::Client::new()
.post(&hook_url)
.json(&serde_json::json!({
"session_id": session_id,
"event": "PostToolUse",
"payload": { "approved": approved },
}))
.send()
.await;
if approved {
CommandResult::Approved {
session_id: session_id.to_string(),
}
} else {
CommandResult::Denied {
session_id: session_id.to_string(),
}
}
}
async fn overseer(&self) -> CommandResult {
match self.client.overseer_status().await {
Ok(snap) => CommandResult::OverseerStatus {
enabled: snap.enabled,
handler: snap.handler,
decisions: DecisionCounts {
allow: snap.decisions.0,
block: snap.decisions.1,
flag: snap.decisions.2,
},
},
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn tmux(&self) -> CommandResult {
match self.client.tmux_sessions().await {
Ok(rows) => CommandResult::TmuxSessions(
rows.into_iter()
.map(|r| TmuxSessionSummary {
name: r.name,
managed: r.managed,
})
.collect(),
),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn projects(&self) -> CommandResult {
match self.client.discover_projects().await {
Ok(rows) => CommandResult::DiscoveredProjects(
rows.into_iter()
.map(|r| DiscoveredProjectSummary {
path: r.path,
session_count: r.session_count,
last_session: r.last_session,
})
.collect(),
),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn discover(&self) -> CommandResult {
match self.client.discover_sessions().await {
Ok(count) => CommandResult::Discovered { count },
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn adopt(&self, session: &str) -> CommandResult {
match self.client.adopt_tmux_session(session).await {
Ok(true) => CommandResult::Adopted {
session: session.to_string(),
},
Ok(false) => CommandResult::Error(format!("tmux session {session} not found")),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
pub async fn register_project(&self, path: &str) -> CommandResult {
match self.client.register_project(path).await {
Ok(()) => CommandResult::ProjectRegistered {
path: path.to_string(),
},
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn config(&self, project: &str) -> CommandResult {
match self.client.analyze_config(project).await {
Ok(recs) => CommandResult::ConfigAnalysis {
project: project.to_string(),
recommendations: recs
.into_iter()
.map(|r| RecommendationSummary {
id: r.id,
message: r.message,
})
.collect(),
},
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn snapshot(&self, session: &str) -> CommandResult {
match self.client.snapshot_tmux_session(session).await {
Ok(Some(output)) => CommandResult::Snapshot {
session: session.to_string(),
output,
},
Ok(None) => CommandResult::Error(format!("tmux session {session} not found")),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn kill(&self, session_id: &str) -> CommandResult {
match self.client.kill_session(session_id).await {
Ok(true) => CommandResult::Killed {
session_id: session_id.to_string(),
},
Ok(false) => CommandResult::Error(format!("session {session_id} not found")),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn send(&self, session: &str, prompt: &str) -> CommandResult {
if prompt.trim().is_empty() {
return CommandResult::Error("send: a prompt is required".to_string());
}
let rows = match self.client.sessions().await {
Ok(rows) => rows,
Err(e) => return CommandResult::Error(format!("daemon unreachable: {e}")),
};
let Some(target) = resolve_session(&rows, session) else {
return CommandResult::Error(format!("session {session} not found"));
};
match self.client.send_session_command(&target, prompt).await {
Ok(Some(output)) => CommandResult::CommandSent {
session: target,
output: truncate_output(&output),
},
Ok(None) => CommandResult::Error(format!("session {session} not found")),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn launch(&self, project: &std::path::Path) -> CommandResult {
let workdir = project.to_string_lossy().to_string();
match self.client.launch_session(&workdir).await {
Ok(session) => CommandResult::SessionStarted {
session,
workdir,
deployed: true,
},
Err(e) => CommandResult::Error(format!("launch failed: {e}")),
}
}
async fn connect(&self, project: &std::path::Path) -> CommandResult {
let workdir = project.to_string_lossy().to_string();
match self.client.connect_session(&workdir).await {
Ok(session) => CommandResult::SessionStarted {
session,
workdir,
deployed: false,
},
Err(e) => CommandResult::Error(format!("connect failed: {e}")),
}
}
async fn doctor(&self) -> CommandResult {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
match self.client.doctor(cwd.as_deref()).await {
Ok(report) => CommandResult::Doctor(report),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn coordinator_chat(&self, message: &str) -> CommandResult {
match self.client.coordinator_chat(message, &[]).await {
Ok(Some(outcome)) => {
let reply = match outcome.command_output {
Some(output) => format!("{}\n{output}", outcome.reply),
None => outcome.reply,
};
CommandResult::ChatReply { reply }
}
Ok(None) => CommandResult::Error(
"coordinator chat is not configured (no OpenRouter API key)".to_string(),
),
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
async fn pair_state(&self) -> CommandResult {
match self.client.pair_status().await {
Ok(status) => CommandResult::PairState {
paired: status.paired,
},
Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
}
}
}
pub(crate) fn resolve_session(rows: &[SessionRow], query: &str) -> Option<String> {
if let Some(row) = rows
.iter()
.find(|r| r.id.0.to_string() == query || r.tmux_name == query)
{
return Some(target_of(row));
}
rows.iter()
.find(|r| !r.tmux_name.is_empty() && r.tmux_name.starts_with(query))
.map(target_of)
}
fn target_of(row: &SessionRow) -> String {
if row.tmux_name.is_empty() {
row.id.0.to_string()
} else {
row.tmux_name.clone()
}
}
pub(crate) fn truncate_output(text: &str) -> String {
if text.chars().count() <= MAX_OUTPUT_CHARS {
return text.to_string();
}
let head: String = text.chars().take(MAX_OUTPUT_CHARS).collect();
format!("{head}\n… (output truncated)")
}
fn status_label(status: crate::core::session::SessionStatus) -> String {
serde_json::to_value(status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string())
}