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