use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
pub use tmux_router::Tmux;
#[cfg(test)]
pub use tmux_router::IsolatedTmux;
pub use tmux_router::{RegistryEntry as SessionEntry, RegistryLock, Registry as SessionRegistry};
pub use tmux_router::PaneMoveOp;
const SESSIONS_FILE: &str = ".agent-doc/sessions.json";
pub fn registry_path() -> PathBuf {
PathBuf::from(SESSIONS_FILE)
}
pub fn capture_pane(tmux: &Tmux, pane_id: &str) -> Result<String> {
let output = tmux
.cmd()
.args(["capture-pane", "-t", pane_id, "-p"])
.output()
.context("failed to capture tmux pane")?;
if !output.status.success() {
anyhow::bail!("tmux capture-pane failed for {}", pane_id);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn swap_pane_guarded(
tmux: &Tmux,
src: &str,
dst: &str,
expected_session: &str,
) -> Result<()> {
let src_session = tmux
.cmd()
.args(["display-message", "-t", src, "-p", "#{session_name}"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if src_session != expected_session {
anyhow::bail!(
"swap_pane_guarded: src pane {} is in session '{}', expected '{}' — refusing cross-session swap",
src, src_session, expected_session
);
}
let dst_session = tmux
.cmd()
.args(["display-message", "-t", dst, "-p", "#{session_name}"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if dst_session != expected_session {
anyhow::bail!(
"swap_pane_guarded: dst pane {} is in session '{}', expected '{}' — refusing cross-session swap",
dst, dst_session, expected_session
);
}
tmux.swap_pane(src, dst)
}
pub fn send_key(tmux: &Tmux, pane_id: &str, key: &str) -> Result<()> {
let status = tmux
.cmd()
.args(["send-keys", "-t", pane_id, key])
.status()
.context("failed to send key to tmux pane")?;
if !status.success() {
anyhow::bail!("tmux send-keys failed for pane {}", pane_id);
}
Ok(())
}
pub(crate) fn load() -> Result<SessionRegistry> {
let path = PathBuf::from(SESSIONS_FILE);
if !path.exists() {
return Ok(SessionRegistry::new());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", SESSIONS_FILE))?;
let registry: SessionRegistry = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", SESSIONS_FILE))?;
Ok(registry)
}
pub(crate) fn save(registry: &SessionRegistry) -> Result<()> {
let path = PathBuf::from(SESSIONS_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(registry)?;
std::fs::write(&path, content)
.with_context(|| format!("failed to write {}", SESSIONS_FILE))?;
Ok(())
}
pub fn pane_pid(pane_id: &str) -> Result<u32> {
let output = Command::new("tmux")
.args(["display-message", "-t", pane_id, "-p", "#{pane_pid}"])
.output()
.context("failed to query tmux pane PID")?;
if !output.status.success() {
anyhow::bail!("tmux display-message failed for pane {}", pane_id);
}
let pid_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
pid_str
.parse::<u32>()
.with_context(|| format!("invalid PID '{}' for pane {}", pid_str, pane_id))
}
pub fn register(session_id: &str, pane_id: &str, file: &str) -> Result<()> {
register_with_pid(session_id, pane_id, file, std::process::id())
}
pub fn register_with_pid(session_id: &str, pane_id: &str, file: &str, pid: u32) -> Result<()> {
let window = pane_window(pane_id).unwrap_or_default();
register_full(session_id, pane_id, file, pid, &window)
}
pub fn register_full(
session_id: &str,
pane_id: &str,
file: &str,
pid: u32,
window: &str,
) -> Result<()> {
let _lock = RegistryLock::acquire(®istry_path())?;
let mut registry = load()?;
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let started = chrono_now();
let stale_keys: Vec<String> = registry
.iter()
.filter(|(k, e)| e.pane == pane_id && k.as_str() != session_id)
.map(|(k, _)| k.clone())
.collect();
for key in &stale_keys {
eprintln!(
"[registry] removing stale session {} (was pane {})",
key, pane_id
);
registry.remove(key);
}
registry.insert(
session_id.to_string(),
SessionEntry {
pane: pane_id.to_string(),
pid,
cwd,
started,
file: file.to_string(),
window: window.to_string(),
},
);
save(®istry)
}
pub fn pane_window(pane_id: &str) -> Result<String> {
let output = Command::new("tmux")
.args(["display-message", "-t", pane_id, "-p", "#{window_id}"])
.output()
.context("failed to query tmux window ID")?;
if !output.status.success() {
anyhow::bail!("tmux display-message failed for pane {}", pane_id);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn lookup(session_id: &str) -> Result<Option<String>> {
let registry = load()?;
Ok(registry.get(session_id).map(|e| e.pane.clone()))
}
pub fn lookup_entry(session_id: &str) -> Result<Option<SessionEntry>> {
let registry = load()?;
Ok(registry.get(session_id).cloned())
}
pub fn current_pane() -> Result<String> {
if let Ok(pane) = std::env::var("TMUX_PANE") {
return Ok(pane);
}
let output = Command::new("tmux")
.args(["display-message", "-p", "#{pane_id}"])
.output()
.context("failed to query tmux for active pane — is tmux running?")?;
if !output.status.success() {
anyhow::bail!("tmux display-message failed — not inside tmux and no tmux server found");
}
let pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
if pane.is_empty() {
anyhow::bail!("tmux returned empty pane ID");
}
Ok(pane)
}
pub fn pane_by_position(position: &str) -> Result<String> {
let output = Command::new("tmux")
.args([
"list-panes",
"-F",
"#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height}",
])
.output()
.context("failed to query tmux panes")?;
if !output.status.success() {
anyhow::bail!("tmux list-panes failed");
}
let text = String::from_utf8_lossy(&output.stdout);
let mut panes: Vec<(String, u32, u32, u32, u32)> = Vec::new();
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
let id = parts[0].to_string();
let left: u32 = parts[1].parse().unwrap_or(0);
let top: u32 = parts[2].parse().unwrap_or(0);
let width: u32 = parts[3].parse().unwrap_or(0);
let height: u32 = parts[4].parse().unwrap_or(0);
panes.push((id, left, top, width, height));
}
}
if panes.is_empty() {
anyhow::bail!("no panes found in current tmux window");
}
if panes.len() == 1 {
return Ok(panes[0].0.clone());
}
let selected = match position {
"left" => panes.iter().min_by_key(|p| p.1),
"right" => panes.iter().max_by_key(|p| p.1 + p.3),
"top" => panes.iter().min_by_key(|p| p.2),
"bottom" => panes.iter().max_by_key(|p| p.2 + p.4),
_ => anyhow::bail!(
"invalid position '{}' — use left, right, top, or bottom",
position
),
};
match selected {
Some(pane) => Ok(pane.0.clone()),
None => anyhow::bail!("could not resolve pane for position '{}'", position),
}
}
pub fn pane_by_position_in_window(position: &str, window: &str) -> Result<String> {
let output = Command::new("tmux")
.args([
"list-panes",
"-t",
window,
"-F",
"#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height}",
])
.output()
.context("failed to query tmux panes")?;
if !output.status.success() {
anyhow::bail!("tmux list-panes failed for window {}", window);
}
let text = String::from_utf8_lossy(&output.stdout);
let mut panes: Vec<(String, u32, u32, u32, u32)> = Vec::new();
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
let id = parts[0].to_string();
let left: u32 = parts[1].parse().unwrap_or(0);
let top: u32 = parts[2].parse().unwrap_or(0);
let width: u32 = parts[3].parse().unwrap_or(0);
let height: u32 = parts[4].parse().unwrap_or(0);
panes.push((id, left, top, width, height));
}
}
if panes.is_empty() {
anyhow::bail!("no panes found in tmux window {}", window);
}
if panes.len() == 1 {
return Ok(panes[0].0.clone());
}
let selected = match position {
"left" => panes.iter().min_by_key(|p| p.1),
"right" => panes.iter().max_by_key(|p| p.1 + p.3),
"top" => panes.iter().min_by_key(|p| p.2),
"bottom" => panes.iter().max_by_key(|p| p.2 + p.4),
_ => anyhow::bail!(
"invalid position '{}' — use left, right, top, or bottom",
position
),
};
match selected {
Some(pane) => Ok(pane.0.clone()),
None => anyhow::bail!("could not resolve pane for position '{}'", position),
}
}
pub fn in_tmux() -> bool {
std::env::var("TMUX").is_ok()
}
fn chrono_now() -> String {
let output = Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
.output();
match output {
Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Err(_) => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn registry_roundtrip() {
let dir = TempDir::new().unwrap();
let _guard = std::env::set_current_dir(dir.path());
let mut reg = SessionRegistry::new();
reg.insert(
"test-session".to_string(),
SessionEntry {
pane: "%42".to_string(),
pid: 12345,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: "test.md".to_string(),
window: String::new(),
},
);
save(®).unwrap();
let loaded = load().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded["test-session"].pane, "%42");
}
#[test]
fn load_empty_returns_empty_map() {
let dir = TempDir::new().unwrap();
let _guard = std::env::set_current_dir(dir.path());
let reg = load().unwrap();
assert!(reg.is_empty());
}
#[test]
fn pane_alive_returns_false_for_nonexistent() {
assert!(!Tmux::default_server().pane_alive("%99999"));
}
#[test]
fn registry_multiple_sessions_isolated() {
let mut reg = SessionRegistry::new();
reg.insert(
"session-a".to_string(),
SessionEntry {
pane: "%10".to_string(),
pid: 1000,
cwd: "/tmp/a".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
reg.insert(
"session-b".to_string(),
SessionEntry {
pane: "%20".to_string(),
pid: 2000,
cwd: "/tmp/b".to_string(),
started: "2026-01-01T00:01:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
let json = serde_json::to_string_pretty(®).unwrap();
let loaded: SessionRegistry = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded["session-a"].pane, "%10");
assert_eq!(loaded["session-b"].pane, "%20");
assert_ne!(loaded["session-a"].pane, loaded["session-b"].pane);
assert_ne!(loaded["session-a"].pid, loaded["session-b"].pid);
}
#[test]
fn registry_overwrite_existing_session() {
let mut reg = SessionRegistry::new();
reg.insert(
"session-x".to_string(),
SessionEntry {
pane: "%old".to_string(),
pid: 100,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
reg.insert(
"session-x".to_string(),
SessionEntry {
pane: "%new".to_string(),
pid: 200,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:05:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
assert_eq!(reg.len(), 1);
assert_eq!(reg["session-x"].pane, "%new");
assert_eq!(reg["session-x"].pid, 200);
}
#[test]
fn prune_removes_dead_panes_from_map() {
let tmux = Tmux::default_server();
let mut reg = SessionRegistry::new();
reg.insert(
"dead-session-1".to_string(),
SessionEntry {
pane: "%99998".to_string(),
pid: 1,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
reg.insert(
"dead-session-2".to_string(),
SessionEntry {
pane: "%99997".to_string(),
pid: 2,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: String::new(),
window: String::new(),
},
);
let before = reg.len();
reg.retain(|_, entry| tmux.pane_alive(&entry.pane));
let removed = before - reg.len();
assert_eq!(removed, 2);
assert!(reg.is_empty());
}
#[test]
fn tmux_isolated_server_not_running_initially() {
let t = IsolatedTmux::new("agent-doc-test-not-running");
assert!(!t.running());
}
#[test]
fn tmux_create_session_and_verify() {
let t = IsolatedTmux::new("agent-doc-test-create-session");
let tmp = TempDir::new().unwrap();
let pane_id = t.new_session("test-session", tmp.path()).unwrap();
assert!(!pane_id.is_empty(), "pane_id should not be empty");
assert!(pane_id.starts_with('%'), "pane_id should start with %");
assert!(t.running());
assert!(t.session_exists("test-session"));
assert!(t.pane_alive(&pane_id));
}
#[test]
fn tmux_session_exists_returns_false_for_missing() {
let t = IsolatedTmux::new("agent-doc-test-session-missing");
let tmp = TempDir::new().unwrap();
t.new_session("existing", tmp.path()).unwrap();
assert!(t.session_exists("existing"));
assert!(!t.session_exists("nonexistent"));
}
#[test]
fn tmux_new_window_creates_second_pane() {
let t = IsolatedTmux::new("agent-doc-test-new-window");
let tmp = TempDir::new().unwrap();
let pane1 = t.new_session("test", tmp.path()).unwrap();
let pane2 = t.new_window("test", tmp.path()).unwrap();
assert_ne!(pane1, pane2, "two windows should have different pane IDs");
assert!(t.pane_alive(&pane1));
assert!(t.pane_alive(&pane2));
}
#[test]
fn tmux_send_keys_to_pane() {
let t = IsolatedTmux::new("agent-doc-test-send-keys");
let tmp = TempDir::new().unwrap();
let pane_id = t.new_session("test", tmp.path()).unwrap();
t.send_keys(&pane_id, "echo hello").unwrap();
}
#[test]
fn tmux_pane_alive_returns_false_after_kill() {
let t = IsolatedTmux::new("agent-doc-test-pane-kill");
let tmp = TempDir::new().unwrap();
let pane_id = t.new_session("test", tmp.path()).unwrap();
assert!(t.pane_alive(&pane_id));
let _pane2 = t.new_window("test", tmp.path()).unwrap();
let _ = t.cmd().args(["kill-pane", "-t", &pane_id]).status();
assert!(!t.pane_alive(&pane_id));
}
#[test]
fn tmux_auto_start_cascade_no_server() {
let t = IsolatedTmux::new("agent-doc-test-autostart-no-server");
let tmp = TempDir::new().unwrap();
assert!(!t.running());
let pane_id = t.auto_start("claude", tmp.path()).unwrap();
assert!(!pane_id.is_empty());
assert!(t.running());
assert!(t.session_exists("claude"));
assert!(t.pane_alive(&pane_id));
}
#[test]
fn tmux_auto_start_cascade_no_session() {
let t = IsolatedTmux::new("agent-doc-test-autostart-no-session");
let tmp = TempDir::new().unwrap();
t.new_session("other", tmp.path()).unwrap();
assert!(t.running());
assert!(!t.session_exists("claude"));
let pane_id = t.auto_start("claude", tmp.path()).unwrap();
assert!(t.session_exists("claude"));
assert!(t.pane_alive(&pane_id));
}
#[test]
fn tmux_auto_start_cascade_session_exists() {
let t = IsolatedTmux::new("agent-doc-test-autostart-exists");
let tmp = TempDir::new().unwrap();
let pane1 = t.new_session("claude", tmp.path()).unwrap();
let pane2 = t.auto_start("claude", tmp.path()).unwrap();
assert_ne!(pane1, pane2, "should be a different pane (new window)");
assert!(t.pane_alive(&pane1));
assert!(t.pane_alive(&pane2));
}
#[test]
#[ignore] fn register_full_deduplicates_pane() {
let dir = TempDir::new().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
std::fs::create_dir_all(".agent-doc").unwrap();
let mut reg = SessionRegistry::new();
reg.insert(
"session-a".to_string(),
SessionEntry {
pane: "%42".to_string(),
pid: 100,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: "old-file.md".to_string(),
window: "@1".to_string(),
},
);
reg.insert(
"session-b".to_string(),
SessionEntry {
pane: "%42".to_string(),
pid: 100,
cwd: "/tmp".to_string(),
started: "2026-01-01T00:01:00Z".to_string(),
file: "another-old.md".to_string(),
window: "@1".to_string(),
},
);
save(®).unwrap();
register_full("session-c", "%42", "new-file.md", 200, "@1").unwrap();
let loaded = load().unwrap();
assert!(loaded.contains_key("session-c"), "new session should exist");
assert!(!loaded.contains_key("session-a"), "old session-a should be removed");
assert!(!loaded.contains_key("session-b"), "old session-b should be removed");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded["session-c"].file, "new-file.md");
}
}