Skip to main content

orch/channels/
tmux.rs

1//! tmux channel — bridges agent sessions to the transport layer.
2//!
3//! This is the key channel for live session streaming:
4//! - Captures pane output via `tmux capture-pane`
5//! - Streams output chunks through the transport broadcast
6//! - Accepts input via `tmux send-keys`
7//!
8//! Users can watch agent sessions in real-time from any connected channel,
9//! and even join/intervene by sending input through the transport.
10
11use super::{Channel, IncomingMessage, OutgoingMessage};
12use crate::channels::transport::Transport;
13use crate::cmd::CommandErrorContext;
14use async_trait::async_trait;
15use std::fmt;
16use std::fmt::Debug;
17use std::fmt::Formatter;
18use std::sync::Arc;
19use tokio::process::Command;
20use tokio::sync::mpsc;
21use tokio::sync::mpsc::Receiver;
22
23pub struct TmuxChannel {
24    /// Shared transport for pushing output
25    transport: Option<Arc<Transport>>,
26}
27
28impl Debug for TmuxChannel {
29    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
30        f.debug_struct("TmuxChannel")
31            .field("has_transport", &self.transport.is_some())
32            .finish()
33    }
34}
35
36impl TmuxChannel {
37    pub fn with_transport(transport: Arc<Transport>) -> Self {
38        Self {
39            transport: Some(transport),
40        }
41    }
42}
43
44#[async_trait]
45impl Channel for TmuxChannel {
46    fn name(&self) -> &'static str {
47        "tmux"
48    }
49
50    async fn start(&self) -> anyhow::Result<Receiver<IncomingMessage>> {
51        let (_tx, rx) = mpsc::channel(64);
52        tracing::info!("tmux channel started");
53
54        // Output streaming is handled by CaptureService now.
55        // The transport-backed capture loop has been removed to avoid duplicate output.
56        if self.transport.is_none() {
57            tracing::warn!("tmux channel started without transport - output streaming disabled");
58        }
59
60        Ok(rx)
61    }
62
63    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()> {
64        // Send input to a tmux session via send-keys
65        let session = &msg.thread_id; // thread_id = tmux session name
66        send_keys(session, &msg.body).await
67    }
68
69    async fn health_check(&self) -> anyhow::Result<()> {
70        // Check if tmux server is running
71        let output = Command::new("tmux")
72            .args(["list-sessions"])
73            .output_with_context()
74            .await?;
75        if !output.status.success() {
76            anyhow::bail!("tmux server not running");
77        }
78        Ok(())
79    }
80}
81
82/// Send keystrokes to a tmux session.
83pub async fn send_keys(session: &str, text: &str) -> anyhow::Result<()> {
84    let output = Command::new("tmux")
85        .args(["send-keys", "-t", session, text, "Enter"])
86        .output_with_context()
87        .await?;
88    if !output.status.success() {
89        let stderr = String::from_utf8_lossy(&output.stderr);
90        anyhow::bail!("tmux send-keys failed: {stderr}");
91    }
92    Ok(())
93}
94
95/// Capture the current content of a tmux pane.
96pub async fn capture_pane(session: &str) -> anyhow::Result<String> {
97    let output = Command::new("tmux")
98        .args(["capture-pane", "-t", session, "-p", "-S", "-"])
99        .output_with_context()
100        .await?;
101    if !output.status.success() {
102        let stderr = String::from_utf8_lossy(&output.stderr);
103        anyhow::bail!("tmux capture-pane failed: {stderr}");
104    }
105    Ok(String::from_utf8_lossy(&output.stdout).to_string())
106}
107
108/// List active tmux sessions matching the orch- prefix.
109pub async fn list_orch_sessions() -> anyhow::Result<Vec<String>> {
110    let output = Command::new("tmux")
111        .args(["list-sessions", "-F", "#{session_name}"])
112        .output_with_context()
113        .await?;
114    if !output.status.success() {
115        return Ok(vec![]);
116    }
117    Ok(String::from_utf8_lossy(&output.stdout)
118        .lines()
119        .filter(|s| s.starts_with("orch-"))
120        .map(String::from)
121        .collect())
122}
123
124/// Check if a tmux session is dead (no longer exists).
125///
126/// Best-effort check — spawns `tmux has-session` to verify.
127/// Used by the capture service to detect ended sessions and send
128/// final output chunks.
129pub async fn is_session_dead(session: &str) -> bool {
130    let output = Command::new("tmux")
131        .args(["has-session", "-t", session])
132        .output_with_context()
133        .await;
134
135    match output {
136        Ok(output) => !output.status.success(),
137        Err(_) => true,
138    }
139}
140
141// Capture loop removed — output streaming handled by CaptureService.