use std::path::Path;
use std::process::Command;
use crate::core::external_session::ExternalSession;
use crate::core::session::Session;
use crate::core::tmux::TmuxTarget;
use crate::daemon::error::DaemonError;
use crate::daemon::tmux::{AdoptedSession, SessionSnapshot, TmuxDriver};
pub struct TmuxService;
impl TmuxService {
pub fn capture(session: &Session, lines: u32) -> String {
match TmuxDriver::discover() {
Ok(driver) => {
let target = TmuxTarget::session(&session.tmux_name);
match driver.capture(&target, Some(lines)) {
Ok(text) => text,
Err(e) => {
tracing::warn!("tmux capture failed for {}: {e}", session.tmux_name);
String::new()
}
}
}
Err(_) => {
tracing::info!(
"tmux unavailable; capture for {} skipped",
session.tmux_name
);
String::new()
}
}
}
pub fn send_command(session: &Session, command: &str) {
match TmuxDriver::discover() {
Ok(driver) => {
let target = TmuxTarget::session(&session.tmux_name);
if let Err(e) = driver.send_line(&target, command) {
tracing::warn!("tmux send_line failed for {}: {e}", session.tmux_name);
}
}
Err(_) => {
tracing::info!(
"tmux unavailable; command for {} not sent",
session.tmux_name
);
}
}
}
pub fn kill_best_effort(tmux_name: &str) {
match TmuxDriver::discover() {
Ok(driver) => {
if let Err(e) = driver.kill_session(tmux_name) {
tracing::warn!(
"tmux kill_session failed for {tmux_name} (may already be gone): {e}"
);
}
}
Err(_) => {
tracing::info!("tmux unavailable; kill for {tmux_name} skipped");
}
}
}
pub fn list_all() -> Vec<ExternalSession> {
match TmuxDriver::discover() {
Ok(driver) => driver.list_all_sessions().unwrap_or_else(|e| {
tracing::warn!("tmux list_all_sessions failed: {e}");
Vec::new()
}),
Err(_) => {
tracing::info!("tmux unavailable; tmux session list is empty");
Vec::new()
}
}
}
pub fn adopt(name: &str) -> Result<AdoptedSession, DaemonError> {
let driver = TmuxDriver::discover().map_err(|_| DaemonError::SessionNotFound {
id: name.to_string(),
})?;
driver.adopt_session(name).map_err(|e| {
tracing::warn!("tmux adopt {name} failed: {e}");
DaemonError::SessionNotFound {
id: name.to_string(),
}
})
}
pub fn spawn_claude(tmux_name: &str, workdir: &Path) -> Result<(), DaemonError> {
if which_claude().is_none() {
return Err(DaemonError::Unprocessable(
"claude binary not found on PATH".to_string(),
));
}
let driver = TmuxDriver::discover()
.map_err(|e| DaemonError::Unprocessable(format!("tmux unavailable for spawn: {e}")))?;
let workdir_str = workdir.to_string_lossy().into_owned();
driver
.create_session(tmux_name, Some(&workdir_str))
.map_err(|e| {
DaemonError::Internal(format!("tmux new-session for {tmux_name} failed: {e}"))
})?;
let target = TmuxTarget::session(tmux_name);
if let Err(e) = driver.send_line(&target, &crate::daemon::spawn_command::relaunch_command())
{
Self::kill_best_effort(tmux_name);
return Err(DaemonError::Internal(format!(
"failed to launch claude in {tmux_name}: {e}"
)));
}
Ok(())
}
pub fn snapshot(name: &str, lines: u32) -> Result<SessionSnapshot, DaemonError> {
let driver = TmuxDriver::discover().map_err(|_| DaemonError::SessionNotFound {
id: name.to_string(),
})?;
driver.monitor_session(name, lines).map_err(|e| {
tracing::warn!("tmux snapshot for {name} failed: {e}");
DaemonError::SessionNotFound {
id: name.to_string(),
}
})
}
}
fn which_claude() -> Option<String> {
#[cfg(test)]
if let Some(override_value) = claude_lookup_override() {
return override_value;
}
let output = Command::new("which").arg("claude").output().ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() { None } else { Some(path) }
}
#[cfg(test)]
static CLAUDE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
static CLAUDE_OVERRIDE: std::sync::RwLock<Option<Option<String>>> = std::sync::RwLock::new(None);
#[cfg(test)]
pub(crate) fn claude_lookup_override() -> Option<Option<String>> {
CLAUDE_OVERRIDE.read().ok().and_then(|g| g.clone())
}
#[cfg(test)]
pub(crate) struct ClaudeOverrideGuard {
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl Drop for ClaudeOverrideGuard {
fn drop(&mut self) {
if let Ok(mut guard) = CLAUDE_OVERRIDE.write() {
*guard = None;
}
}
}
#[cfg(test)]
pub(crate) fn set_claude_lookup_override(value: Option<Option<String>>) -> ClaudeOverrideGuard {
let lock = match CLAUDE_TEST_MUTEX.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if let Ok(mut guard) = CLAUDE_OVERRIDE.write() {
*guard = value;
}
ClaudeOverrideGuard { _lock: lock }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::{ControlModel, SessionId};
#[test]
fn capture_without_tmux_is_empty() {
let session = Session::new(SessionId::new(), "/tmp/p", ControlModel::Tmux, None);
let _ = TmuxService::capture(&session, 10);
}
#[test]
fn kill_best_effort_is_noop_for_unknown_session() {
TmuxService::kill_best_effort("tmpm-definitely-no-such-session-xyz");
}
#[test]
fn list_all_without_tmux_is_empty() {
let _ = TmuxService::list_all();
}
#[test]
fn adopt_missing_session_is_not_found() {
let result = TmuxService::adopt("tmpm-definitely-no-such-session-xyz");
assert!(result.is_err());
}
#[test]
fn snapshot_missing_session_is_not_found() {
let result = TmuxService::snapshot("no-such-session-xyz", 10);
assert!(result.is_err());
}
#[test]
fn spawn_claude_without_binary_is_unprocessable() {
let _guard = set_claude_lookup_override(Some(None));
let result = TmuxService::spawn_claude("tmpm-test-no-bin", Path::new("/tmp"));
match result {
Err(DaemonError::Unprocessable(msg)) => {
assert!(
msg.contains("claude binary"),
"message should name the missing binary: {msg}"
);
}
other => panic!("expected Unprocessable, got {other:?}"),
}
}
struct KillOnDrop(String);
impl Drop for KillOnDrop {
fn drop(&mut self) {
if let Ok(driver) = TmuxDriver::discover() {
let _ = driver.kill_session(&self.0);
}
}
}
fn tmux_session_exists(name: &str) -> bool {
match TmuxDriver::discover() {
Ok(driver) => driver
.list_all_sessions()
.map(|sessions| sessions.iter().any(|s| s.name == name))
.unwrap_or(false),
Err(_) => false,
}
}
#[test]
#[ignore = "requires a real tmux host; run with --include-ignored"]
fn spawn_claude_rollback_kills_session_on_send_failure() {
if !TmuxDriver::is_available() {
eprintln!("tmux unavailable; skipping real-tmux rollback test");
return;
}
let driver = TmuxDriver::discover().expect("tmux available");
let name = format!("tmpm-rollback-test-{}", std::process::id());
let _cleanup = KillOnDrop(name.clone());
driver
.create_session(&name, Some("/tmp"))
.expect("create_session should succeed on a real tmux host");
assert!(
tmux_session_exists(&name),
"precondition: the session must exist right after create_session"
);
TmuxService::kill_best_effort(&name);
assert!(
!tmux_session_exists(&name),
"after rollback the orphaned tmux session must be gone (#1456)"
);
}
#[test]
fn spawn_claude_without_tmux_is_unprocessable_when_tmux_missing() {
let _guard = set_claude_lookup_override(Some(Some("/fake/claude".into())));
let result = TmuxService::spawn_claude("tmpm-test-no-tmux", Path::new("/tmp"));
if TmuxDriver::is_available() {
if result.is_ok()
&& let Ok(driver) = TmuxDriver::discover()
{
let _ = driver.kill_session("tmpm-test-no-tmux");
}
} else {
assert!(
matches!(result, Err(DaemonError::Unprocessable(_))),
"expected Unprocessable on no-tmux host, got {result:?}"
);
}
}
}