use std::ffi::OsStr;
use std::process::Stdio;
use async_trait::async_trait;
use tokio::process::Command;
use crate::error::{BosunError, Result};
use crate::tmux::parse::{parse_list_sessions, LIST_SESSIONS_FORMAT};
use crate::tmux::session::TmuxSession;
#[derive(Debug, Clone, Default)]
pub struct CreateSpec {
pub name: String,
pub display_name: Option<String>,
pub path: String,
pub command: String,
pub metadata: Option<SessionMetadata>,
}
#[derive(Debug, Clone, Default)]
pub struct SessionMetadata {
pub display_name: String,
pub path: String,
pub agent: String,
pub args: String,
pub claude_session_mode: String,
pub claude_skip_permissions: bool,
pub codex_yolo: bool,
}
#[async_trait]
pub trait TmuxClient: Send + Sync {
async fn list_sessions(&self) -> Result<Vec<TmuxSession>>;
async fn capture_pane(&self, session: &str) -> Result<Vec<u8>>;
async fn create_session(&self, spec: &CreateSpec) -> Result<String>;
async fn kill_session(&self, session: &str) -> Result<()>;
async fn set_display_name(&self, session: &str, display: &str) -> Result<()>;
async fn get_session_metadata(&self, session: &str) -> Result<Option<SessionMetadata>>;
async fn restart_in_place(&self, session: &str, command: &str) -> Result<()>;
}
#[derive(Debug, Clone)]
pub struct TokioTmuxClient {
socket: Option<String>,
}
impl TokioTmuxClient {
pub fn new() -> Self {
Self { socket: None }
}
#[allow(dead_code)]
pub fn with_socket(socket: impl Into<String>) -> Self {
Self {
socket: Some(socket.into()),
}
}
pub(crate) fn cmd(&self) -> Command {
let mut c = Command::new("tmux");
if let Some(sock) = &self.socket {
c.arg("-L").arg(sock);
}
c.stdin(Stdio::null());
c.kill_on_drop(true);
c
}
#[allow(dead_code)]
pub fn socket(&self) -> Option<&str> {
self.socket.as_deref()
}
}
impl Default for TokioTmuxClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TmuxClient for TokioTmuxClient {
async fn list_sessions(&self) -> Result<Vec<TmuxSession>> {
let mut cmd = self.cmd();
cmd.arg("list-sessions").arg("-F").arg(LIST_SESSIONS_FORMAT);
let output = cmd.output().await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => BosunError::TmuxNotInstalled,
_ => BosunError::Io(e),
})?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout);
return parse_list_sessions(&s);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no server running")
|| stderr.contains("no sessions")
|| (stderr.contains("error connecting") && stderr.contains("No such file or directory"))
{
return Ok(Vec::new());
}
Err(BosunError::Tmux(format!(
"list-sessions failed ({}): {}",
output.status,
stderr.trim()
)))
}
async fn capture_pane(&self, session: &str) -> Result<Vec<u8>> {
let mut cmd = self.cmd();
cmd.arg("capture-pane")
.arg("-p")
.arg("-e")
.arg("-J")
.arg("-t")
.arg(session);
let output = cmd.output().await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => BosunError::TmuxNotInstalled,
_ => BosunError::Io(e),
})?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("can't find session") || stderr.contains("no server running") {
return Ok(Vec::new());
}
Err(BosunError::Tmux(format!(
"capture-pane {} failed ({}): {}",
session,
output.status,
stderr.trim()
)))
}
async fn create_session(&self, spec: &CreateSpec) -> Result<String> {
let mut cmd = self.cmd();
cmd.arg("new-session").arg("-d").arg("-s").arg(&spec.name);
if !spec.path.is_empty() {
cmd.arg("-c").arg(&spec.path);
}
let output = cmd.output().await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => BosunError::TmuxNotInstalled,
_ => BosunError::Io(e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BosunError::Tmux(format!(
"new-session -s {} failed: {}",
spec.name,
stderr.trim()
)));
}
if let Some(display) = &spec.display_name {
let mut set = self.cmd();
set.arg("set-option")
.arg("-t")
.arg(&spec.name)
.arg("@bosun_display")
.arg(display);
if let Err(e) = set.output().await {
tracing::warn!("set @bosun_display on {}: {}", spec.name, e);
}
}
if let Some(meta) = &spec.metadata {
for (key, value) in metadata_options(meta) {
let mut set = self.cmd();
set.arg("set-option")
.arg("-t")
.arg(&spec.name)
.arg(key)
.arg(&value);
if let Err(e) = set.output().await {
tracing::warn!("set {} on {}: {}", key, spec.name, e);
}
}
}
if !spec.command.is_empty() {
let mut literal = self.cmd();
literal
.arg("send-keys")
.arg("-l")
.arg("-t")
.arg(&spec.name)
.arg("--")
.arg(&spec.command);
if let Err(e) = literal.output().await {
tracing::warn!("send-keys -l to {}: {}", spec.name, e);
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut enter = self.cmd();
enter
.arg("send-keys")
.arg("-t")
.arg(&spec.name)
.arg("Enter");
if let Err(e) = enter.output().await {
tracing::warn!("send-keys Enter to {}: {}", spec.name, e);
}
}
Ok(spec.name.clone())
}
async fn kill_session(&self, session: &str) -> Result<()> {
let mut cmd = self.cmd();
cmd.arg("kill-session").arg("-t").arg(session);
let output = cmd.output().await.map_err(BosunError::Io)?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("can't find session") || stderr.contains("no server running") {
return Ok(());
}
Err(BosunError::Tmux(format!(
"kill-session {} failed: {}",
session,
stderr.trim()
)))
}
async fn set_display_name(&self, session: &str, display: &str) -> Result<()> {
let mut cmd = self.cmd();
cmd.arg("set-option")
.arg("-t")
.arg(session)
.arg("@bosun_display")
.arg(display);
let output = cmd.output().await.map_err(BosunError::Io)?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(BosunError::Tmux(format!(
"set @bosun_display on {}: {}",
session,
stderr.trim()
)))
}
async fn get_session_metadata(&self, session: &str) -> Result<Option<SessionMetadata>> {
const SEP: &str = "|||";
let fmt = format!(
"#{{@bosun_display}}{SEP}#{{@bosun_path}}{SEP}#{{@bosun_agent}}{SEP}#{{@bosun_args}}{SEP}#{{@bosun_claude_session_mode}}{SEP}#{{@bosun_claude_skip_permissions}}{SEP}#{{@bosun_codex_yolo}}",
SEP = SEP
);
let mut cmd = self.cmd();
cmd.arg("display-message")
.arg("-p")
.arg("-t")
.arg(session)
.arg(&fmt);
let output = cmd.output().await.map_err(BosunError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BosunError::Tmux(format!(
"display-message on {}: {}",
session,
stderr.trim()
)));
}
let raw = String::from_utf8_lossy(&output.stdout);
let line = raw.trim_end_matches('\n');
let parts: Vec<&str> = line.split(SEP).collect();
if parts.len() != 7 {
return Ok(None);
}
if parts[2].is_empty() {
return Ok(None);
}
Ok(Some(SessionMetadata {
display_name: parts[0].to_string(),
path: parts[1].to_string(),
agent: parts[2].to_string(),
args: parts[3].to_string(),
claude_session_mode: if parts[4].is_empty() {
"New".to_string()
} else {
parts[4].to_string()
},
claude_skip_permissions: parts[5] == "1",
codex_yolo: parts[6] == "1",
}))
}
async fn restart_in_place(&self, session: &str, command: &str) -> Result<()> {
for _ in 0..2 {
let mut cc = self.cmd();
cc.arg("send-keys").arg("-t").arg(session).arg("C-c");
if let Err(e) = cc.output().await {
tracing::warn!("restart_in_place send C-c to {}: {}", session, e);
}
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
}
if command.is_empty() {
return Ok(());
}
let mut literal = self.cmd();
literal
.arg("send-keys")
.arg("-l")
.arg("-t")
.arg(session)
.arg("--")
.arg(command);
if let Err(e) = literal.output().await {
tracing::warn!("restart_in_place send-keys -l to {}: {}", session, e);
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut enter = self.cmd();
enter.arg("send-keys").arg("-t").arg(session).arg("Enter");
if let Err(e) = enter.output().await {
tracing::warn!("restart_in_place send Enter to {}: {}", session, e);
}
Ok(())
}
}
fn metadata_options(m: &SessionMetadata) -> Vec<(&'static str, String)> {
vec![
("@bosun_path", m.path.clone()),
("@bosun_agent", m.agent.clone()),
("@bosun_args", m.args.clone()),
("@bosun_claude_session_mode", m.claude_session_mode.clone()),
(
"@bosun_claude_skip_permissions",
if m.claude_skip_permissions { "1" } else { "0" }.to_string(),
),
(
"@bosun_codex_yolo",
if m.codex_yolo { "1" } else { "0" }.to_string(),
),
]
}
#[allow(dead_code)]
pub(crate) fn sync_tmux<I, S>(socket: Option<&str>, args: I) -> std::process::Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut c = std::process::Command::new("tmux");
if let Some(sock) = socket {
c.arg("-L").arg(sock);
}
for a in args {
c.arg(a);
}
c
}