#![allow(dead_code)]
use std::process::Command;
use tracing::warn;
use crate::core::external_session::ExternalSession;
use crate::core::oauth_token::OAUTH_TOKEN_ENV_VAR;
use crate::core::tmux::{TmuxCommand, TmuxTarget, tmux_argv};
use crate::core::{Error, Result};
fn find_quoted_value_end(s: &str) -> usize {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes[i..].starts_with(b"'\\''") {
i += 4; continue;
}
return i + 1; }
i += 1;
}
bytes.len()
}
fn redact_oauth_token(s: &str) -> String {
let needle = format!("{OAUTH_TOKEN_ENV_VAR}=");
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(pos) = rest.find(&needle) {
out.push_str(&rest[..pos + needle.len()]);
let after = &rest[pos + needle.len()..];
if let Some(stripped) = after.strip_prefix('\'') {
let end = find_quoted_value_end(stripped);
out.push_str("'<redacted>'");
rest = &stripped[end..];
} else {
let end = after.find(char::is_whitespace).unwrap_or(after.len());
out.push_str("<redacted>");
rest = &after[end..];
}
}
out.push_str(rest);
out
}
#[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 path = trusty_common::bin_resolve::resolve_binary("tmux").ok_or_else(|| {
Error::Protocol(
"tmux not found on PATH or in well-known dirs (e.g. /opt/homebrew/bin); \
use the PTY or SDK control model"
.into(),
)
})?;
let path = path
.to_str()
.ok_or_else(|| Error::Protocol("resolved tmux path is not valid UTF-8".into()))?
.to_string();
Ok(Self { tmux_path: path })
}
pub fn is_available() -> bool {
Self::discover().is_ok()
}
pub fn ensure_server_up(&self) -> Result<()> {
crate::core::tmux::ensure_server_up(&self.tmux_path).map_err(Error::Protocol)
}
fn run(&self, cmd: &TmuxCommand) -> Result<String> {
let output = crate::core::tmux::run_tmux_with_bin(&self.tmux_path, cmd)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let argv = tmux_argv(cmd);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Err(Error::Protocol(redact_oauth_token(&format!(
"tmux {argv:?} failed: {stderr}"
))))
}
}
pub fn create_session(&self, name: &str, workdir: Option<&str>) -> Result<()> {
let outcome =
crate::core::tmux::create_managed_session(Some(&self.tmux_path), name, workdir)
.inspect(|o| crate::core::tmux::warn_if_options_unverified(o, name))?;
if outcome.output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&outcome.output.stderr).into_owned();
Err(Error::Protocol(redact_oauth_token(&format!(
"tmux new-session for {name} failed: {stderr}"
))))
}
}
pub fn apply_scrollback_options(&self) {
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let opts = crate::core::trusty_tools_config::resolve_tmux_options(&config);
for cmd in crate::core::tmux::scrollback_option_commands(opts.history_limit, opts.mouse) {
if let Err(e) = self.run(&cmd) {
warn!("tmux scrollback/mouse option {cmd:?} failed (non-fatal): {e}");
}
}
}
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_keys_literal(&self, target: &TmuxTarget, text: &str) -> Result<()> {
self.run(&TmuxCommand::SendKeys {
target: target.clone(),
keys: text.to_string(),
literal: true,
})?;
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 set_environment(&self, session: &str, key: &str, value: &str) -> Result<()> {
self.run(&TmuxCommand::SetEnvironment {
session: session.to_string(),
key: key.to_string(),
value: value.to_string(),
})?;
Ok(())
}
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_managed_panes(&self) -> Result<Vec<crate::daemon::orphan_gc::PaneInfo>> {
let output = Command::new(&self.tmux_path)
.args([
"list-panes",
"-a",
"-F",
"#{session_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_id}",
])
.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-panes -a: {stderr}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter(|l| !l.is_empty())
.filter_map(Self::parse_managed_pane_row)
.collect())
}
fn parse_managed_pane_row(line: &str) -> Option<crate::daemon::orphan_gc::PaneInfo> {
let mut parts = line.splitn(4, '\t');
let session_name = parts.next().filter(|s| !s.is_empty())?.to_string();
let pane_current_command = parts.next().unwrap_or("").trim().to_string();
let pane_pid = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
let pane_id = parts
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(crate::daemon::orphan_gc::PaneInfo {
session_name,
pane_current_command,
pane_pid,
pane_id,
})
}
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 pane_current_path(&self, session_name: &str) -> Option<std::path::PathBuf> {
let output = Command::new(&self.tmux_path)
.args([
"display-message",
"-t",
session_name,
"-p",
"#{pane_current_path}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(std::path::PathBuf::from(trimmed))
}
}
pub fn pane_id(&self, session_name: &str) -> Option<String> {
let output = Command::new(&self.tmux_path)
.args(["display-message", "-t", session_name, "-p", "#{pane_id}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn adopt_session(&self, name: &str) -> Result<AdoptedSession> {
let windows = self.list_windows(name)?;
let panes = self.list_panes(name)?;
let origin = crate::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())
}
pub fn pane_exists(&self, session_name: &str, pane_id: &str) -> bool {
match self.list_panes(session_name) {
Ok(rows) => rows
.iter()
.any(|row| row.split(':').next() == Some(pane_id)),
Err(_) => false,
}
}
pub fn pane_exists_checked(&self, session_name: &str, pane_id: &str) -> Option<bool> {
match self.list_panes(session_name) {
Ok(rows) => Some(
rows.iter()
.any(|row| row.split(':').next() == Some(pane_id)),
),
Err(_) => None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AdoptedSession {
pub name: String,
pub origin: crate::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)]
#[path = "tmux_tests.rs"]
mod tests;