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