use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use tokio::time::Duration;
use tracing::{debug, warn};
use crate::control::event::SessionEvent;
use crate::control::id::ControlSessionId;
use crate::core::tmux::TmuxTarget;
use crate::daemon::tmux::TmuxDriver;
use super::{SessionBackend, SessionInput};
pub const TMUX_POLL_INTERVAL_MS: u64 = 200;
const _: () = assert!(TMUX_POLL_INTERVAL_MS > 0, "poll interval must be > 0ms");
pub fn tmux_session_name(session_id: &ControlSessionId) -> String {
if let Some((proj, n)) = session_id.parse_parts() {
format!("tm:{proj}:{n}")
} else {
format!("tm:{}", session_id.as_str())
}
}
fn build_claude_command(claude_cmd: &str, prompt_file: Option<&std::path::Path>) -> Result<String> {
let quoted_cmd = shlex::try_quote(claude_cmd)
.context("claude_cmd is not shell-quotable")?
.into_owned();
match prompt_file {
Some(pf) => {
let quoted_pf = shlex::try_quote(&pf.to_string_lossy())
.context("prompt_file path is not shell-quotable")?
.into_owned();
Ok(format!(
"{quoted_cmd} --append-system-prompt-file {quoted_pf}"
))
}
None => Ok(quoted_cmd),
}
}
pub(crate) fn pane_claude_line(
config_root: Option<&std::path::Path>,
claude_cmd: &str,
prompt_file: Option<&std::path::Path>,
) -> Result<String> {
let assignments = crate::core::alt_screen::configured_shell_assignments(
crate::core::alt_screen::configured_alternate_screen_in(config_root),
);
Ok(format!(
"env{} {assignments} {}",
crate::core::claude_env_scrub::env_unset_flags(),
build_claude_command(claude_cmd, prompt_file)?
))
}
pub struct TmuxBackend {
session_id: ControlSessionId,
tmux_name: String,
driver: TmuxDriver,
spawned: bool,
capture_lines: u32,
last_pane_content: String,
}
impl TmuxBackend {
pub fn new(
session_id: ControlSessionId,
workdir: impl Into<std::path::PathBuf>,
prompt_file: Option<std::path::PathBuf>,
claude_cmd: String,
capture_lines: u32,
) -> Result<Self> {
let workdir = workdir.into();
let tmux_name = tmux_session_name(&session_id);
let driver = TmuxDriver::discover()
.with_context(|| format!("tmux unavailable for session {session_id}"))?;
let workdir_str = workdir.to_string_lossy().to_string();
driver
.create_session(&tmux_name, Some(&workdir_str))
.with_context(|| {
format!("failed to create tmux session {tmux_name} in {workdir_str}")
})?;
let cmd_str = pane_claude_line(
crate::core::alt_screen::operator_config_root().as_deref(),
&claude_cmd,
prompt_file.as_deref(),
)
.with_context(|| format!("building claude command for session {session_id}"))?;
let target = TmuxTarget::session(&tmux_name);
driver
.send_command_line(&target, &cmd_str)
.with_context(|| format!("failed to start claude in tmux session {tmux_name}"))?;
debug!(
session_id = %session_id,
tmux_name = %tmux_name,
"tmux backend spawned"
);
Ok(Self {
session_id,
tmux_name,
driver,
spawned: true,
capture_lines,
last_pane_content: String::new(),
})
}
}
#[async_trait]
impl SessionBackend for TmuxBackend {
async fn send(&mut self, msg: SessionInput) -> Result<()> {
let target = TmuxTarget::session(&self.tmux_name);
self.driver
.send_line(&target, &msg.text)
.with_context(|| format!("send-keys to tmux session {} failed", self.tmux_name))
}
async fn recv(&mut self) -> Option<Result<Vec<SessionEvent>>> {
let target = TmuxTarget::session(&self.tmux_name);
match self
.driver
.capture(&target, Some(self.capture_lines))
.with_context(|| format!("capture-pane for tmux session {} failed", self.tmux_name))
{
Ok(output) => {
let is_empty = output.trim().is_empty();
let is_unchanged = !is_empty && output == self.last_pane_content;
if is_empty || is_unchanged {
tokio::time::sleep(Duration::from_millis(TMUX_POLL_INTERVAL_MS)).await;
return Some(Ok(vec![]));
}
self.last_pane_content = output.clone();
let event = SessionEvent::Output {
session_id: self.session_id.clone(),
raw: output,
structured: None, ts: Utc::now(),
};
Some(Ok(vec![event]))
}
Err(e) => {
warn!(
session_id = %self.session_id,
tmux_name = %self.tmux_name,
"tmux capture-pane failed (session may have exited): {e}"
);
None }
}
}
async fn stop(mut self) -> Result<()> {
if self.spawned {
self.spawned = false;
if let Err(e) = self.driver.kill_session(&self.tmux_name) {
warn!(
session_id = %self.session_id,
tmux_name = %self.tmux_name,
"kill_session failed (may already be gone): {e}"
);
}
}
Ok(())
}
}
impl Drop for TmuxBackend {
fn drop(&mut self) {
if self.spawned
&& let Err(e) = self.driver.kill_session(&self.tmux_name)
{
debug!(
session_id = %self.session_id,
"TmuxBackend::drop: kill_session failed: {e}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tmux_backend_session_name() {
let id = ControlSessionId::new("trusty-tools", 0);
assert_eq!(tmux_session_name(&id), "tm:trusty-tools:0");
let id2 = ControlSessionId::new("my-proj", 3);
assert_eq!(tmux_session_name(&id2), "tm:my-proj:3");
}
#[test]
fn tmux_backend_session_name_hyphenated_project() {
let id = ControlSessionId::new("some-long-project", 7);
assert_eq!(tmux_session_name(&id), "tm:some-long-project:7");
}
#[test]
#[ignore = "spawns a session on a private tmux server when tmux is on PATH"]
#[serial_test::serial]
fn tmux_backend_constructs_without_spawning() {
use crate::core::tmux::with_tmux_binary;
use crate::test_support::tmux_session::{PrivateTmuxServer, ScratchTmuxSession};
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
let new_backend = || {
TmuxBackend::new(
ControlSessionId::new("proj", 0),
"/tmp",
None,
"true".into(),
100,
)
};
let missing = std::path::PathBuf::from("/nonexistent/tmux-6542");
let result = rt.block_on(with_tmux_binary(missing, async { new_backend() }));
assert!(result.is_err(), "an unusable tmux must be an Err");
let tmux_bin = crate::core::tmux::resolve_tmux_binary_or_bare();
if !ScratchTmuxSession::tmux_available(&tmux_bin) {
eprintln!("tmux not available; skipping the spawn half");
return;
}
let server = PrivateTmuxServer::new(&tmux_bin, "backend");
let shim = std::path::PathBuf::from(server.shim_bin());
let live = || server.query(&["has-session", "-t", "=tm_proj_0"]).is_some();
rt.block_on(with_tmux_binary(shim, async {
let backend = new_backend().expect("new spawns on the private server");
assert!(live(), "the session must exist on the private server");
drop(backend);
assert!(!live(), "Drop must kill the session it spawned (#1452)");
}));
}
#[test]
fn build_claude_command_quotes_metacharacters() {
let injection = std::path::Path::new("/tmp/x; touch /tmp/PWNED; #");
let cmd = build_claude_command("claude", Some(injection)).expect("quotable");
let expected = [
"claude",
"--append-system-prompt-file",
"'/tmp/x; touch /tmp/PWNED; #'",
]
.join(" ");
assert_eq!(cmd, expected, "metacharacters must be single-quoted");
}
#[test]
fn build_claude_command_quotes_claude_cmd() {
let cmd = build_claude_command("claude; rm -rf /", None).expect("quotable");
assert!(
cmd.contains("'claude; rm -rf /'"),
"claude_cmd metacharacters must be single-quoted, got: {cmd}"
);
}
#[test]
fn build_claude_command_plain() {
assert_eq!(build_claude_command("claude", None).unwrap(), "claude");
let pf = "/tmp/p.txt";
let cmd = build_claude_command("claude", Some(std::path::Path::new(pf))).unwrap();
let expected = ["claude", "--append-system-prompt-file", pf].join(" ");
assert_eq!(cmd, expected);
}
#[test]
fn pane_claude_line_follows_the_configured_renderer() {
for (alternate_screen, want) in [
(true, " CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=0 "),
(false, " CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 "),
] {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(
root.path().join("config.yaml"),
format!("tmux:\n alternate_screen: {alternate_screen}\n"),
)
.expect("write config");
let line =
pane_claude_line(Some(root.path()), "claude; rm -rf /", None).expect("quotable");
assert!(line.starts_with("env "), "an `env` prefix: {line}");
assert!(line.contains(want), "want {want:?}: {line}");
assert!(line.ends_with("'claude; rm -rf /'"), "still quoted: {line}");
}
}
#[test]
fn pane_claude_line_never_passes_on_the_profile_stamp() {
let line = pane_claude_line(None, "claude", None).expect("quotable");
let unset = crate::core::claude_env_scrub::parse_env_unset_vars(&line);
assert!(
unset.contains(&"TRUSTY_MPM_SESSION_PROFILE"),
"the control-plane pane line must unset the profile stamp: {line}"
);
}
}