use axum::{
extract::{
Path, Query, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use serde::{Deserialize, Serialize};
#[cfg(unix)]
use std::os::unix::io::RawFd;
use std::sync::{Arc, Mutex};
use tracing::{debug, error, info, warn};
use crate::AppState;
use crate::tmux;
use std::time::Duration;
use tokio::sync::oneshot;
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum ClientControl {
#[serde(rename = "resize")]
Resize { cols: u16, rows: u16 },
#[serde(rename = "ping")]
Ping,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
#[allow(dead_code)] enum ServerControl<'a> {
#[serde(rename = "attached")]
Attached { session: &'a str },
#[serde(rename = "pong")]
Pong,
#[serde(rename = "error")]
Error { message: &'a str },
#[serde(rename = "exit")]
Exit { code: Option<i32> },
#[serde(rename = "agent_state")]
AgentState {
agent_kind: Option<&'a str>,
state: &'a str,
attention_reason: Option<&'a str>,
agent_event: Option<&'a str>,
agent_nonce: Option<&'a str>,
},
}
#[derive(Debug, Deserialize)]
pub struct TerminalQuery {
pub cols: Option<u16>,
pub rows: Option<u16>,
}
const TMUX_ESCAPE_TIME_MS: &str = "10";
#[cfg(unix)]
fn build_tmux_attach_cmd(tmux_name: &str, cwd: &str) -> CommandBuilder {
let mut cmd = CommandBuilder::new("tmux");
cmd.args([
"set-option",
"-s",
"escape-time",
TMUX_ESCAPE_TIME_MS,
";",
"new-session",
"-A",
"-s",
tmux_name,
]);
cmd.cwd(cwd);
cmd.env("TERM", "xterm-256color");
cmd
}
#[cfg(windows)]
fn build_tmux_attach_cmd(tmux_name: &str, cwd: &str) -> CommandBuilder {
let mut cmd = CommandBuilder::new("tmux");
cmd.args(["new-session", "-A", "-s", tmux_name]);
cmd.cwd(cwd);
cmd.env("TERM", "xterm-256color");
cmd
}
#[cfg(windows)]
async fn apply_escape_time_workaround() {
use std::sync::atomic::{AtomicBool, Ordering};
static APPLIED: AtomicBool = AtomicBool::new(false);
if APPLIED.load(Ordering::Relaxed) {
return;
}
match tokio::process::Command::new("tmux")
.args(["set-option", "-s", "escape-time", TMUX_ESCAPE_TIME_MS])
.output()
.await
{
Ok(out) if out.status.success() => {
APPLIED.store(true, Ordering::Relaxed);
}
Ok(out) => {
debug!("escape-time set-option failed: {}", String::from_utf8_lossy(&out.stderr));
}
Err(e) => debug!("escape-time set-option spawn failed: {}", e),
}
}
pub async fn ws_terminal_handler(
ws: WebSocketUpgrade,
Path(session_id): Path<String>,
Query(query): Query<TerminalQuery>,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_terminal(socket, session_id, query, state))
}
pub async fn ws_external_terminal_handler(
ws: WebSocketUpgrade,
Path(tmux_name): Path<String>,
Query(query): Query<TerminalQuery>,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_external_terminal(socket, tmux_name, query, state))
}
async fn handle_terminal(ws: WebSocket, session_id: String, query: TerminalQuery, state: AppState) {
let tmux_name: Option<(String,)> =
sqlx::query_as("SELECT tmux_session_name FROM sessions WHERE id = ?")
.bind(&session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let tmux_name = match tmux_name {
Some((name,)) => name,
None => {
let (mut sender, _) = ws.split();
let msg = serde_json::to_string(&ServerControl::Error { message: "session not found" })
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
info!("terminal WS connected: session={} tmux={}", session_id, tmux_name);
if let Err(e) = state.activity_monitor.ensure_session(&tmux_name).await {
warn!("failed to ensure control mode for session {}: {}", tmux_name, e);
}
let hook_enabled: bool = sqlx::query_as("SELECT hook_enabled FROM sessions WHERE id = ?")
.bind(&session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.map(|(enabled,): (bool,)| enabled)
.unwrap_or(false);
let cwd: Option<(String,)> = sqlx::query_as("SELECT workspace_path FROM sessions WHERE id = ?")
.bind(&session_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let cwd = cwd
.map(|(p,)| p)
.unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()));
let cols = query.cols.filter(|&c| c > 0 && c <= 1000).unwrap_or(80);
let rows = query.rows.filter(|&r| r > 0 && r <= 1000).unwrap_or(24);
let pty_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
info!("terminal PTY initial size: {}x{} for session={}", cols, rows, session_id);
#[cfg(windows)]
tokio::spawn(apply_escape_time_workaround());
let pty_system = native_pty_system();
let pty_pair = match pty_system.openpty(pty_size) {
Ok(pair) => pair,
Err(e) => {
error!("failed to open PTY: {}", e);
let (mut sender, _) = ws.split();
let msg =
serde_json::to_string(&ServerControl::Error { message: "failed to open PTY" })
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
let cmd = build_tmux_attach_cmd(&tmux_name, &cwd);
let mut child = match pty_pair.slave.spawn_command(cmd) {
Ok(child) => child,
Err(e) => {
error!("failed to spawn tmux: {}", e);
let (mut sender, _) = ws.split();
let msg = serde_json::to_string(&ServerControl::Error {
message: "failed to start terminal",
})
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
let mut pty_reader = pty_pair.master.try_clone_reader().expect("clone reader");
let master_pty: Arc<Mutex<Option<Box<dyn portable_pty::MasterPty>>>> =
Arc::new(Mutex::new(Some(pty_pair.master)));
let (mut ws_tx, mut ws_rx) = ws.split();
let attached_msg =
serde_json::to_string(&ServerControl::Attached { session: &tmux_name }).unwrap();
if ws_tx.send(Message::Text(attached_msg.into())).await.is_err() {
return;
}
let (agent_tx, mut agent_rx) = tokio::sync::mpsc::channel::<String>(16);
let (pty_out_tx, mut pty_out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut buf = [0u8; 8192];
loop {
match pty_reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if pty_out_tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
debug!("PTY reader exited");
});
let mut ws_tx2 = ws_tx; let forward_handle = tokio::spawn(async move {
loop {
tokio::select! {
Some(data) = pty_out_rx.recv() => {
if ws_tx2.send(Message::Binary(data.into())).await.is_err() {
break;
}
}
Some(json_text) = agent_rx.recv() => {
if ws_tx2.send(Message::Text(json_text.into())).await.is_err() {
break;
}
}
else => break,
}
}
});
let (pty_in_tx, mut pty_in_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
#[cfg(unix)]
{
let pty_fd: RawFd = master_pty
.lock()
.unwrap()
.as_ref()
.and_then(|m| m.as_raw_fd())
.expect("master PTY has a raw fd on unix");
std::thread::spawn(move || {
while let Some(data) = pty_in_rx.blocking_recv() {
let mut written = 0;
while written < data.len() {
match tmux::pty_io::write_pty(pty_fd, &data[written..]) {
Ok(0) => return,
Ok(n) => written += n,
Err(e) => {
if e.raw_os_error() == Some(libc::EBADF) {
debug!("PTY fd closed, writer thread exiting");
} else {
warn!("PTY write failed: {}", e);
}
return;
}
}
}
}
debug!("PTY writer exited");
});
}
#[cfg(windows)]
{
let writer = master_pty
.lock()
.unwrap()
.as_ref()
.and_then(|m| m.take_writer().ok())
.expect("master PTY has a writer on windows");
std::thread::spawn(move || {
let mut writer = writer;
while let Some(data) = pty_in_rx.blocking_recv() {
let mut written = 0;
while written < data.len() {
match tmux::pty_io::write_pty(writer.as_mut(), &data[written..]) {
Ok(0) => return,
Ok(n) => written += n,
Err(e) => {
warn!("PTY write failed: {}", e);
return;
}
}
}
}
debug!("PTY writer exited");
});
}
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let agent_tx_clone = agent_tx.clone();
let tmux_name_clone = tmux_name.clone();
let agent_handle: Option<tokio::task::JoinHandle<()>> = if hook_enabled {
Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_nonce: Option<String> = None;
let mut consecutive_timeouts: u32 = 0;
loop {
tokio::select! {
_ = interval.tick() => {
let result = tokio::time::timeout(
Duration::from_secs(2),
tmux::get_session_agent_option(&tmux_name_clone),
)
.await;
match result {
Ok(Ok(Some(snapshot))) => {
consecutive_timeouts = 0;
let current_nonce = snapshot.agent_nonce.clone();
if current_nonce != last_nonce {
last_nonce = current_nonce;
let msg = serde_json::json!({
"type": "agent_state",
"agent_kind": snapshot.agent_kind.as_str(),
"state": snapshot.agent_state.as_str(),
"attention_reason": snapshot.attention_reason.map(|r| r.as_str()),
"agent_event": snapshot.agent_event,
"agent_nonce": snapshot.agent_nonce,
});
if let Ok(text) = serde_json::to_string(&msg) {
let _ = agent_tx_clone.send(text).await;
}
}
}
Ok(Ok(None)) => {
consecutive_timeouts = 0;
}
Ok(Err(e)) => {
warn!("agent poll error for {}: {}", tmux_name_clone, e);
consecutive_timeouts += 1;
}
Err(_elapsed) => {
warn!("agent poll timeout for {}", tmux_name_clone);
consecutive_timeouts += 1;
}
}
if consecutive_timeouts >= 3 {
warn!(
"agent poll stopping after {} consecutive failures for {}",
consecutive_timeouts, tmux_name_clone
);
let msg = serde_json::json!({
"type": "agent_state",
"state": "unknown",
});
if let Ok(text) = serde_json::to_string(&msg) {
let _ = agent_tx_clone.send(text).await;
}
break;
}
}
_ = &mut shutdown_rx => {
debug!("agent poll task received shutdown signal");
break;
}
}
}
debug!("agent poll task exited cleanly");
}))
} else {
None
};
let resize_pty = Arc::clone(&master_pty);
let read_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
#[allow(clippy::collapsible_match)]
Ok(Message::Binary(data)) => {
if pty_in_tx.send(data.to_vec()).await.is_err() {
break;
}
}
Ok(Message::Text(text)) => {
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
if cols > 0
&& cols <= 1000
&& rows > 0
&& rows <= 1000
&& let Ok(guard) = resize_pty.lock()
&& let Some(master) = guard.as_ref()
{
let new_size =
PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
if let Err(e) = master.resize(new_size) {
warn!("PTY resize failed: {}", e);
}
}
}
ClientControl::Ping => {
debug!("ping received");
}
}
}
}
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
});
let child_pid = child.process_id();
let (exit_tx, mut exit_rx) = tokio::sync::mpsc::channel::<Option<i32>>(1);
tokio::task::spawn_blocking(move || {
let status = child.wait();
let code = status.ok().map(|s| s.exit_code() as i32);
let _ = exit_tx.blocking_send(code);
});
tokio::select! {
_ = forward_handle => {
debug!("PTY→WS forward ended");
}
_ = read_handle => {
debug!("WS→PTY read ended");
}
code = exit_rx.recv() => {
info!("tmux process exited: {:?}", code);
}
}
let _ = shutdown_tx.send(());
if let Some(handle) = agent_handle {
let _ = handle.await;
debug!("agent poll task joined");
}
if let Some(pid) = child_pid {
tmux::pty_io::kill_session_process(pid);
debug!("sent SIGHUP to tmux client pid={}", pid);
}
if let Ok(mut guard) = master_pty.lock() {
guard.take();
}
info!("terminal WS disconnected: session={}", session_id);
}
async fn handle_external_terminal(
ws: WebSocket,
tmux_name: String,
query: TerminalQuery,
state: AppState,
) {
info!("terminal WS connected (external): tmux={}", tmux_name);
if let Err(e) = state.activity_monitor.ensure_session(&tmux_name).await {
warn!("failed to ensure control mode for external session {}: {}", tmux_name, e);
}
let _hook_enabled = false;
let cwd = tmux::pane_cwd(&tmux_name)
.await
.unwrap_or_else(|_| std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()));
let cols = query.cols.filter(|&c| c > 0 && c <= 1000).unwrap_or(80);
let rows = query.rows.filter(|&r| r > 0 && r <= 1000).unwrap_or(24);
let pty_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
info!("terminal PTY initial size: {}x{} for tmux={}", cols, rows, tmux_name);
#[cfg(windows)]
tokio::spawn(apply_escape_time_workaround());
let pty_system = native_pty_system();
let pty_pair = match pty_system.openpty(pty_size) {
Ok(pair) => pair,
Err(e) => {
error!("failed to open PTY: {}", e);
let (mut sender, _) = ws.split();
let msg =
serde_json::to_string(&ServerControl::Error { message: "failed to open PTY" })
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
let cmd = build_tmux_attach_cmd(&tmux_name, &cwd);
let mut child = match pty_pair.slave.spawn_command(cmd) {
Ok(child) => child,
Err(e) => {
error!("failed to spawn tmux: {}", e);
let (mut sender, _) = ws.split();
let msg = serde_json::to_string(&ServerControl::Error {
message: "failed to start terminal",
})
.unwrap();
let _ = sender.send(Message::Text(msg.into())).await;
return;
}
};
let mut pty_reader = pty_pair.master.try_clone_reader().expect("clone reader");
let master_pty: Arc<Mutex<Option<Box<dyn portable_pty::MasterPty>>>> =
Arc::new(Mutex::new(Some(pty_pair.master)));
let (mut ws_tx, mut ws_rx) = ws.split();
let attached_msg =
serde_json::to_string(&ServerControl::Attached { session: &tmux_name }).unwrap();
if ws_tx.send(Message::Text(attached_msg.into())).await.is_err() {
return;
}
let (_agent_tx, mut agent_rx) = tokio::sync::mpsc::channel::<String>(16);
let (pty_out_tx, mut pty_out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut buf = [0u8; 8192];
loop {
match pty_reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if pty_out_tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
debug!("PTY reader exited");
});
let mut ws_tx2 = ws_tx;
let forward_handle = tokio::spawn(async move {
loop {
tokio::select! {
Some(data) = pty_out_rx.recv() => {
if ws_tx2.send(Message::Binary(data.into())).await.is_err() {
break;
}
}
Some(json_text) = agent_rx.recv() => {
if ws_tx2.send(Message::Text(json_text.into())).await.is_err() {
break;
}
}
else => break,
}
}
});
let (pty_in_tx, mut pty_in_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
#[cfg(unix)]
{
let pty_fd: RawFd = master_pty
.lock()
.unwrap()
.as_ref()
.and_then(|m| m.as_raw_fd())
.expect("master PTY has a raw fd on unix");
std::thread::spawn(move || {
while let Some(data) = pty_in_rx.blocking_recv() {
let mut written = 0;
while written < data.len() {
match tmux::pty_io::write_pty(pty_fd, &data[written..]) {
Ok(0) => return,
Ok(n) => written += n,
Err(e) => {
if e.raw_os_error() == Some(libc::EBADF) {
debug!("PTY fd closed, writer thread exiting");
} else {
warn!("PTY write failed: {}", e);
}
return;
}
}
}
}
debug!("PTY writer exited");
});
}
#[cfg(windows)]
{
let writer = master_pty
.lock()
.unwrap()
.as_ref()
.and_then(|m| m.take_writer().ok())
.expect("master PTY has a writer on windows");
std::thread::spawn(move || {
let mut writer = writer;
while let Some(data) = pty_in_rx.blocking_recv() {
let mut written = 0;
while written < data.len() {
match tmux::pty_io::write_pty(writer.as_mut(), &data[written..]) {
Ok(0) => return,
Ok(n) => written += n,
Err(e) => {
warn!("PTY write failed: {}", e);
return;
}
}
}
}
debug!("PTY writer exited");
});
}
let (_shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let agent_handle: Option<tokio::task::JoinHandle<()>> = None;
let resize_pty = Arc::clone(&master_pty);
let read_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
#[allow(clippy::collapsible_match)]
Ok(Message::Binary(data)) => {
if pty_in_tx.send(data.to_vec()).await.is_err() {
break;
}
}
Ok(Message::Text(text)) => {
if let Ok(ctrl) = serde_json::from_str::<ClientControl>(&text) {
match ctrl {
ClientControl::Resize { cols, rows } => {
if cols > 0
&& cols <= 1000
&& rows > 0
&& rows <= 1000
&& let Ok(guard) = resize_pty.lock()
&& let Some(master) = guard.as_ref()
{
let new_size =
PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
if let Err(e) = master.resize(new_size) {
warn!("PTY resize failed: {}", e);
}
}
}
ClientControl::Ping => {
debug!("ping received");
}
}
}
}
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
});
let child_pid = child.process_id();
let (exit_tx, mut exit_rx) = tokio::sync::mpsc::channel::<Option<i32>>(1);
tokio::task::spawn_blocking(move || {
let status = child.wait();
let code = status.ok().map(|s| s.exit_code() as i32);
let _ = exit_tx.blocking_send(code);
});
tokio::select! {
_ = forward_handle => {
debug!("PTY→WS forward ended");
}
_ = read_handle => {
debug!("WS→PTY read ended");
}
code = exit_rx.recv() => {
info!("tmux process exited: {:?}", code);
}
}
if let Some(handle) = agent_handle {
let _ = handle.await;
debug!("agent poll task joined");
}
if let Some(pid) = child_pid {
tmux::pty_io::kill_session_process(pid);
debug!("sent SIGHUP to tmux client pid={}", pid);
}
if let Ok(mut guard) = master_pty.lock() {
guard.take();
}
info!("terminal WS disconnected (external): tmux={}", tmux_name);
}