#![allow(dead_code)]
use std::process::Command;
use trusty_mpm_core::external_session::ExternalSession;
use trusty_mpm_core::tmux::{TmuxCommand, TmuxTarget, tmux_argv};
use trusty_mpm_core::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionInfo {
pub name: String,
pub created: i64,
pub attached: bool,
}
impl SessionInfo {
pub fn parse(line: &str) -> Result<Self> {
let mut parts = line.splitn(3, ':');
let name = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::Protocol(format!("empty tmux session row: {line:?}")))?
.to_string();
let created = parts
.next()
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| Error::Protocol(format!("bad tmux created field: {line:?}")))?;
let attached = parts.next().map(|s| s == "1").unwrap_or(false);
Ok(Self {
name,
created,
attached,
})
}
}
#[derive(Debug, Clone)]
pub struct TmuxDriver {
tmux_path: String,
}
impl TmuxDriver {
pub fn discover() -> Result<Self> {
let output = Command::new("which").arg("tmux").output()?;
if !output.status.success() {
return Err(Error::Protocol(
"tmux not found on PATH; use the PTY or SDK control model".into(),
));
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
return Err(Error::Protocol(
"`which tmux` returned an empty path".into(),
));
}
Ok(Self { tmux_path: path })
}
pub fn is_available() -> bool {
Self::discover().is_ok()
}
fn run(&self, cmd: &TmuxCommand) -> Result<String> {
let argv = tmux_argv(cmd);
let output = Command::new(&self.tmux_path).args(&argv).output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Err(Error::Protocol(format!("tmux {argv:?} failed: {stderr}")))
}
}
pub fn create_session(&self, name: &str, workdir: Option<&str>) -> Result<()> {
self.run(&TmuxCommand::NewSession {
name: name.to_string(),
workdir: workdir.map(str::to_string),
})?;
Ok(())
}
pub fn kill_session(&self, name: &str) -> Result<()> {
self.run(&TmuxCommand::KillSession {
name: name.to_string(),
})?;
Ok(())
}
pub fn list_sessions(&self) -> Result<Vec<SessionInfo>> {
let argv = tmux_argv(&TmuxCommand::ListSessions);
let output = Command::new(&self.tmux_path).args(&argv).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no server running") || stderr.contains("no sessions") {
return Ok(Vec::new());
}
return Err(Error::Protocol(format!("tmux list-sessions: {stderr}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut sessions = Vec::new();
for line in stdout.lines().filter(|l| !l.is_empty()) {
sessions.push(SessionInfo::parse(line)?);
}
Ok(sessions)
}
pub fn send_line(&self, target: &TmuxTarget, text: &str) -> Result<()> {
self.run(&TmuxCommand::SendKeys {
target: target.clone(),
keys: text.to_string(),
literal: true,
})?;
self.run(&TmuxCommand::SendKeys {
target: target.clone(),
keys: "Enter".to_string(),
literal: false,
})?;
Ok(())
}
pub fn send_interrupt(&self, target: &TmuxTarget) -> Result<()> {
self.run(&TmuxCommand::SendKeys {
target: target.clone(),
keys: "C-c".to_string(),
literal: false,
})?;
Ok(())
}
pub fn capture(&self, target: &TmuxTarget, lines: Option<u32>) -> Result<String> {
self.run(&TmuxCommand::CapturePane {
target: target.clone(),
lines,
})
}
pub fn list_claude_panes(&self) -> Result<String> {
let output = Command::new(&self.tmux_path)
.args([
"list-panes",
"-a",
"-F",
"#{session_name} #{pane_current_command}",
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no server running") || stderr.contains("no sessions") {
return Ok(String::new());
}
return Err(Error::Protocol(format!("tmux list-panes -a: {stderr}")));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn list_all_sessions(&self) -> Result<Vec<ExternalSession>> {
Ok(self
.list_sessions()?
.into_iter()
.map(|s| ExternalSession::new(s.name, s.attached, s.created))
.collect())
}
pub fn monitor_session(&self, name: &str, lines: u32) -> Result<SessionSnapshot> {
let windows = self.list_windows(name)?;
let panes = self.list_panes(name)?;
let output = self.capture(&TmuxTarget::session(name), Some(lines))?;
Ok(SessionSnapshot {
name: name.to_string(),
windows,
panes,
output,
captured_at: chrono::Utc::now().timestamp(),
})
}
pub fn adopt_session(&self, name: &str) -> Result<AdoptedSession> {
let windows = self.list_windows(name)?;
let panes = self.list_panes(name)?;
let origin = trusty_mpm_core::external_session::SessionOrigin::classify(name);
Ok(AdoptedSession {
name: name.to_string(),
origin,
windows,
panes,
adopted_at: chrono::Utc::now().timestamp(),
})
}
fn list_windows(&self, name: &str) -> Result<Vec<String>> {
let raw = self.run(&TmuxCommand::ListWindows {
name: name.to_string(),
})?;
Ok(raw
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
fn list_panes(&self, name: &str) -> Result<Vec<String>> {
let raw = self.run(&TmuxCommand::ListPanes {
name: name.to_string(),
})?;
Ok(raw
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AdoptedSession {
pub name: String,
pub origin: trusty_mpm_core::external_session::SessionOrigin,
pub windows: Vec<String>,
pub panes: Vec<String>,
pub adopted_at: i64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionSnapshot {
pub name: String,
pub windows: Vec<String>,
pub panes: Vec<String>,
pub output: String,
pub captured_at: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_session_row() {
let info = SessionInfo::parse("trusty-mpm-abc:1700000000:1").unwrap();
assert_eq!(info.name, "trusty-mpm-abc");
assert_eq!(info.created, 1_700_000_000);
assert!(info.attached);
let detached = SessionInfo::parse("s:1:0").unwrap();
assert!(!detached.attached);
}
#[test]
fn rejects_malformed_session_row() {
assert!(SessionInfo::parse("").is_err());
assert!(SessionInfo::parse("name:not-a-number:0").is_err());
}
#[test]
fn driver_reports_availability() {
let available = TmuxDriver::is_available();
if !available {
assert!(TmuxDriver::discover().is_err());
}
}
}