use anyhow::{Context, Result};
use std::path::Path;
use std::time::Duration;
use crate::sessions::{PaneMoveOp, Tmux};
use crate::{frontmatter, prompt, resync, sessions, snapshot, sync};
const TMUX_SESSION_NAME: &str = "claude";
const AGENT_PROCESSES: &[&str] = &["agent-doc", "claude", "node"];
fn is_agent_process(tmux: &Tmux, pane_id: &str) -> bool {
let output = tmux
.cmd()
.args(["display-message", "-t", pane_id, "-p", "#{pane_current_command}"])
.output();
match output {
Ok(o) if o.status.success() => {
let cmd = String::from_utf8_lossy(&o.stdout).trim().to_string();
cmd.is_empty() || AGENT_PROCESSES.contains(&cmd.as_str())
}
_ => true, }
}
fn is_first_column(file: &Path, col_args: &[String]) -> bool {
if col_args.len() < 2 {
return false;
}
let file_str = file.to_string_lossy();
if let Some(first_col) = col_args.first() {
first_col.split(',').any(|f| f.trim() == file_str.as_ref())
} else {
false
}
}
pub fn run(file: &Path, pane: Option<&str>, debounce_ms: u64, col_args: &[String]) -> Result<()> {
run_with_tmux(file, &Tmux::default_server(), pane, debounce_ms, col_args)
}
pub fn run_with_tmux(file: &Path, tmux: &Tmux, pane: Option<&str>, debounce_ms: u64, col_args: &[String]) -> Result<()> {
tracing::debug!(file = %file.display(), pane, debounce_ms, cols = ?col_args, "route::run start");
let _ = resync::prune();
if debounce_ms > 0 {
await_idle(file, Duration::from_millis(debounce_ms))?;
}
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (updated_content, session_id) = frontmatter::ensure_session(&content)?;
if updated_content != content {
std::fs::write(file, &updated_content)
.with_context(|| format!("failed to write {}", file.display()))?;
eprintln!("[route] Generated session UUID: {}", session_id);
}
let target_session = resolve_target_session(tmux, None, true);
eprintln!("[route] target tmux session: {}", target_session);
let file_path = file.to_string_lossy();
let window_arg = col_args.first()
.and_then(|_| tmux.cmd()
.args(["display-message", "-t", &format!("{}:agent-doc", target_session), "-p", "#{window_id}"])
.output().ok())
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let panes_before: Vec<String> = window_arg.as_deref()
.and_then(|w| tmux.list_window_panes(w).ok())
.unwrap_or_default();
let pane_id = resolve_or_create_pane(
tmux, file, pane, col_args,
&session_id, &file_path, &target_session,
);
match pane_id {
Ok(ref _pid) => {
Ok(())
}
Err(e) => {
if let Some(w) = window_arg.as_deref() && let Ok(panes_after) = tmux.list_window_panes(w) {
for p in &panes_after {
if !panes_before.contains(p) {
eprintln!("[route] cleaning up orphaned pane {} (created during failed route)", p);
tracing::warn!(pane = %p, "route: killing orphaned pane from failed route");
let _ = tmux.raw_cmd(&["kill-pane", "-t", p]);
}
}
}
Err(e)
}
}
}
fn resolve_or_create_pane(
tmux: &Tmux,
file: &Path,
pane: Option<&str>,
col_args: &[String],
session_id: &str,
file_path: &str,
target_session: &str,
) -> Result<String> {
tracing::debug!(
session_id = &session_id[..8.min(session_id.len())],
file = file_path,
target_session,
"route::resolve_or_create_pane"
);
let registered = sessions::lookup(session_id)?;
if let Some(ref registered_pane) = registered {
if tmux.pane_alive(registered_pane) {
let pane_session = tmux
.cmd()
.args(["display-message", "-t", registered_pane, "-p", "#{session_name}"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if pane_session == target_session {
rescue_from_stash(tmux, registered_pane, session_id, file_path, target_session);
eprintln!("[route] Pane {} is alive in session '{}'", registered_pane, pane_session);
send_command(tmux, registered_pane, file_path)?;
return Ok(registered_pane.clone());
}
if is_agent_process(tmux, registered_pane) {
eprintln!(
"[route] Pane {} is alive but in session '{}' (config says '{}'). Moving to target session stash.",
registered_pane, pane_session, target_session
);
if let Err(e) = tmux.stash_pane(registered_pane, target_session) {
eprintln!("[route] warning: stash_pane to target session failed: {}", e);
}
rescue_from_stash(tmux, registered_pane, session_id, file_path, target_session);
send_command(tmux, registered_pane, file_path)?;
return Ok(registered_pane.clone());
}
eprintln!(
"[route] Pane {} in session '{}' is running a non-agent process — skipping cross-session rescue",
registered_pane, pane_session
);
} else {
eprintln!("[route] Pane {} is dead", registered_pane);
}
} else {
eprintln!(
"[route] No pane registered for session {}",
&session_id[..std::cmp::min(8, session_id.len())]
);
}
if registered.is_some()
&& let Some(new_pane) = find_target_pane(tmux, pane, target_session)
&& is_agent_process(tmux, &new_pane)
{
eprintln!("[route] Lazy-claiming to pane {} (dead pane)", new_pane);
sessions::register(session_id, &new_pane, file_path)?;
send_command(tmux, &new_pane, file_path)?;
return Ok(new_pane);
}
eprintln!("[route] No active pane found, auto-starting...");
if std::env::var("AGENT_DOC_NO_AUTOSTART").is_ok() {
anyhow::bail!("auto-start skipped (AGENT_DOC_NO_AUTOSTART set)");
}
let split_before = is_first_column(file, col_args);
auto_start_in_session(tmux, file, session_id, file_path, target_session, false, split_before)?;
sessions::lookup(session_id)?
.ok_or_else(|| anyhow::anyhow!("auto-start completed but pane not found in registry"))
}
fn rescue_from_stash(
tmux: &Tmux,
pane_id: &str,
session_id: &str,
file_path: &str,
target_session: &str,
) {
let pane_session = tmux
.cmd()
.args(["display-message", "-t", pane_id, "-p", "#{session_name}"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if pane_session != target_session {
eprintln!(
"[route] Pane {} is in session '{}', not target '{}' — skipping stash rescue",
pane_id, pane_session, target_session
);
return;
}
let pane_win_name = tmux.pane_window(pane_id).ok()
.and_then(|wid| {
tmux.cmd()
.args(["display-message", "-t", &wid, "-p", "#{window_name}"])
.output().ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
})
.unwrap_or_default();
if pane_win_name == "stash" || pane_win_name.starts_with("stash-") {
tracing::debug!(pane_id, window = %pane_win_name, target_session, "route: rescuing pane from stash");
eprintln!(
"[route] Pane {} is in stash window '{}', rescuing to agent-doc window",
pane_id, pane_win_name
);
let agent_doc_window = format!("{}:agent-doc", target_session);
let target_panes = tmux.list_window_panes(&agent_doc_window).unwrap_or_default();
if let Some(target) = target_panes.first() {
match sessions::swap_pane_guarded(tmux, pane_id, target, target_session) {
Ok(()) => eprintln!("[route] Rescued pane {} via swap-pane", pane_id),
Err(e) => {
eprintln!("[route] swap-pane rescue failed ({}), trying join-pane", e);
let _ = PaneMoveOp::new(tmux, pane_id, target).join("-dh");
}
}
}
if let Err(e) = sessions::register(session_id, pane_id, file_path) {
eprintln!("[route] warning: re-register failed: {}", e);
}
}
}
fn send_command(tmux: &Tmux, pane: &str, file_path: &str) -> Result<()> {
let short_name = std::path::Path::new(file_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
let flash_msg = format!("⏳ /agent-doc {}", short_name);
if let Err(e) = tmux
.cmd()
.args(["display-message", "-t", pane, "-d", "2000", &flash_msg])
.status()
{
eprintln!("[route] warning: display-message failed: {}", e);
}
let command = format!("/agent-doc {}", file_path);
tmux.send_keys(pane, &command)?;
if let Err(e) = tmux.select_pane(pane) {
eprintln!("[route] warning: failed to focus pane {}: {}", pane, e);
}
eprintln!("[route] Sent /agent-doc {} → pane {}", file_path, pane);
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
let poll_interval = std::time::Duration::from_millis(300);
let mut enter_retries = 0u32;
while start.elapsed() < timeout {
std::thread::sleep(poll_interval);
if let Ok(content) = sessions::capture_pane(tmux, pane) {
let cmd_still_in_input = content
.lines()
.rev()
.take(5)
.any(|l| {
let stripped = prompt::strip_ansi(l);
stripped.contains("/agent-doc") && stripped.contains(file_path)
});
if !cmd_still_in_input {
eprintln!(
"[route] Command accepted ({:.1}s, {} Enter retries)",
start.elapsed().as_secs_f64(),
enter_retries
);
return Ok(());
}
enter_retries += 1;
if let Err(e) = tmux.send_keys_raw(pane, "Enter") {
eprintln!("[route] warning: retry Enter failed: {}", e);
}
}
}
eprintln!(
"[route] warning: command may not have been accepted after {:.1}s ({} Enter retries)",
start.elapsed().as_secs_f64(),
enter_retries
);
Ok(())
}
fn current_tmux_session(tmux: &Tmux) -> Option<String> {
let output = tmux
.cmd()
.args(["display-message", "-p", "#{session_name}"])
.output()
.ok()?;
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Some(name);
}
}
None
}
fn resolve_target_session(
tmux: &Tmux,
context_session: Option<&str>,
auto_update_config: bool,
) -> String {
if let Some(ctx) = context_session {
return ctx.to_string();
}
let configured = crate::config::project_tmux_session();
if configured.as_ref().is_some_and(|s| tmux.session_alive(s)) {
return configured.unwrap();
}
let fallback = current_tmux_session(tmux)
.unwrap_or_else(|| TMUX_SESSION_NAME.to_string());
if auto_update_config && configured.is_none()
&& let Err(e) = crate::config::update_project_tmux_session(&fallback)
{
eprintln!("warning: failed to update project tmux_session config: {}", e);
}
fallback
}
fn find_target_pane(tmux: &Tmux, explicit_pane: Option<&str>, session_name: &str) -> Option<String> {
let target = explicit_pane
.map(|p| p.to_string())
.or_else(|| tmux.active_pane(session_name));
target.filter(|p| tmux.pane_alive(p))
}
fn has_named_window(tmux: &Tmux, session_name: &str, window_name: &str) -> bool {
let output = tmux
.cmd()
.args([
"list-windows",
"-t",
session_name,
"-F",
"#{window_name}",
])
.output();
match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
text.lines().any(|l| l.trim() == window_name)
}
_ => false,
}
}
fn find_registered_pane_in_session(tmux: &Tmux, session_name: &str, exclude_pane: &str) -> Option<String> {
let registry = sessions::load().ok()?;
for entry in registry.values() {
if entry.pane == exclude_pane || entry.pane.is_empty() {
continue;
}
if !tmux.pane_alive(&entry.pane) {
continue;
}
if let Ok(output) = tmux
.cmd()
.args(["display-message", "-t", &entry.pane, "-p", "#{session_name}"])
.output()
{
let pane_session = String::from_utf8_lossy(&output.stdout).trim().to_string();
if pane_session == session_name {
return Some(entry.pane.clone());
}
}
}
None
}
#[allow(dead_code)]
pub fn auto_start(
tmux: &Tmux,
file: &Path,
session_id: &str,
file_path: &str,
context_session: Option<&str>,
) -> Result<()> {
auto_start_ext(tmux, file, session_id, file_path, context_session, false, false)
}
pub fn provision_pane(
tmux: &Tmux,
file: &Path,
session_id: &str,
file_path: &str,
context_session: Option<&str>,
col_args: &[String],
) -> Result<()> {
let split_before = is_first_column(file, col_args);
auto_start_ext(tmux, file, session_id, file_path, context_session, true, split_before)
}
fn auto_start_ext(
tmux: &Tmux,
file: &Path,
session_id: &str,
file_path: &str,
context_session: Option<&str>,
skip_wait: bool,
split_before: bool,
) -> Result<()> {
let session_name = resolve_target_session(tmux, context_session, true);
auto_start_in_session(tmux, file, session_id, file_path, &session_name, skip_wait, split_before)
}
fn auto_start_in_session(tmux: &Tmux, file: &Path, session_id: &str, file_path: &str, session_name: &str, skip_wait: bool, split_before: bool) -> Result<()> {
if let Ok(canonical) = std::fs::canonicalize(file)
&& let Some(project_root) = snapshot::find_project_root(&canonical)
&& let Ok(hash) = snapshot::doc_hash(file)
{
let starting_dir = project_root.join(".agent-doc/starting");
let lock_path = starting_dir.join(format!("{}.lock", hash));
if lock_path.exists()
&& let Ok(meta) = lock_path.metadata()
&& let Ok(modified) = meta.modified()
&& let Ok(age) = modified.elapsed()
&& age.as_secs() < 5
{
eprintln!(
"[route] startup lock exists for {} (age {:.1}s), skipping auto-start",
file_path, age.as_secs_f64()
);
return Ok(());
}
let _ = std::fs::create_dir_all(&starting_dir);
let _ = std::fs::write(&lock_path, "");
}
let cwd = std::env::current_dir().context("failed to get current directory")?;
let agent_doc_bin = std::env::current_exe()
.unwrap_or_else(|_| "agent-doc".into())
.to_string_lossy()
.to_string();
let existing_pane = if skip_wait {
let window_panes = tmux.list_window_panes(
&format!("{}:agent-doc", session_name)
).unwrap_or_default();
let positional = if split_before {
window_panes.into_iter().next() } else {
window_panes.into_iter().last() };
positional.or_else(|| find_registered_pane_in_session(tmux, session_name, ""))
} else {
find_registered_pane_in_session(tmux, session_name, "")
};
let split_flag = if split_before { "-dbh" } else { "-dh" };
let new_pane = if let Some(ref target) = existing_pane {
match tmux.split_window(target, &cwd, split_flag) {
Ok(pane) => {
eprintln!(
"[route] split-window {} alongside registered pane {} in session '{}' → new pane {}",
split_flag, target, session_name, pane
);
pane
}
Err(e) => {
eprintln!(
"[route] warning: split-window failed alongside {} ({}), stashing in stash window",
target, e
);
let pane = tmux.auto_start(session_name, &cwd)?;
if let Err(stash_err) = tmux.stash_pane(&pane, session_name) {
eprintln!(
"[route] warning: stash failed ({}), pane {} remains in new window",
stash_err, pane
);
} else {
eprintln!("[route] split failed, stashed pane in stash window");
}
pane
}
}
} else {
let has_agent_doc_window = has_named_window(tmux, session_name, "agent-doc");
if has_agent_doc_window {
eprintln!(
"[route] no registered pane but 'agent-doc' window exists in session '{}', creating + stashing",
session_name
);
let pane = tmux.auto_start(session_name, &cwd)?;
if let Err(stash_err) = tmux.stash_pane(&pane, session_name) {
eprintln!(
"[route] warning: stash failed ({}), pane {} remains in new window",
stash_err, pane
);
} else {
eprintln!("[route] stashed new pane {} to avoid window proliferation", pane);
}
pane
} else {
eprintln!("[route] no registered pane found in session '{}', creating new window", session_name);
tmux.auto_start(session_name, &cwd)?
}
};
sessions::register(session_id, &new_pane, file_path)?;
if let Err(e) = tmux.select_pane(&new_pane) {
eprintln!("[route] warning: failed to focus pane {}: {}", new_pane, e);
}
let start_cmd = format!("{} start {}", agent_doc_bin, file_path);
tmux.send_keys(&new_pane, &start_cmd)?;
eprintln!(
"[route] Started Claude for {} in pane {} (session {})",
file_path,
new_pane,
&session_id[..std::cmp::min(8, session_id.len())]
);
if skip_wait {
eprintln!("[route] skip_wait=true — pane created, Claude starting (sync path)");
} else {
eprintln!("[route] Waiting for Claude to initialize...");
if wait_for_claude_ready(tmux, &new_pane, std::time::Duration::from_secs(30)) {
eprintln!("[route] Claude is ready, sending /agent-doc command");
send_command(tmux, &new_pane, file_path)?;
} else {
eprintln!(
"[route] Timed out waiting for Claude. Run `agent-doc route {}` to retry.",
file_path
);
}
}
let _ = file; Ok(())
}
fn wait_for_claude_ready(tmux: &Tmux, pane_id: &str, timeout: std::time::Duration) -> bool {
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(500);
let mut poll_count = 0u32;
while start.elapsed() < timeout {
if pane_has_prompt(tmux, pane_id) {
eprintln!(
"[route] Claude ready after {:.1}s ({} polls)",
start.elapsed().as_secs_f64(),
poll_count
);
return true;
}
poll_count += 1;
if poll_count.is_multiple_of(10)
&& let Ok(content) = sessions::capture_pane(tmux, pane_id) {
let last_line = content
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.map(prompt::strip_ansi)
.unwrap_or_default();
eprintln!(
"[route] Still waiting for Claude ({:.0}s)... last line: {}",
start.elapsed().as_secs_f64(),
&last_line[..std::cmp::min(60, last_line.len())]
);
}
std::thread::sleep(poll_interval);
}
false
}
fn pane_has_prompt(tmux: &Tmux, pane_id: &str) -> bool {
if let Ok(content) = sessions::capture_pane(tmux, pane_id) {
content
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(10)
.any(|l| {
let t = prompt::strip_ansi(l);
let t = t.trim();
t == "❯" || t == ">" || t.starts_with("❯ ") || t.starts_with("> ")
})
} else {
false
}
}
#[allow(dead_code)]
fn sync_after_claim(tmux: &Tmux, pane_id: &str, col_args: &[String]) {
let window_id = match tmux.pane_window(pane_id) {
Ok(w) => w,
Err(_) => return,
};
let effective_col_args: Vec<String> = if !col_args.is_empty() {
col_args.to_vec()
} else {
let registry = match sessions::load() {
Ok(r) => r,
Err(_) => return,
};
registry
.values()
.filter(|entry| {
!entry.pane.is_empty()
&& tmux.pane_alive(&entry.pane)
&& tmux.pane_window(&entry.pane).ok().as_deref() == Some(&window_id)
&& !entry.file.is_empty()
})
.map(|entry| entry.file.clone())
.collect()
};
if effective_col_args.len() < 2 {
return; }
let file_count = effective_col_args.len();
if let Err(e) = sync::run(&effective_col_args, Some(&window_id), None) {
eprintln!("[route] warning: post-claim sync failed: {}", e);
} else {
eprintln!("[route] Auto-synced {} files in window {}", file_count, window_id);
}
}
fn await_idle(file: &Path, debounce: Duration) -> Result<()> {
use std::time::Instant;
let max_wait = debounce * 10;
let poll_interval = Duration::from_millis(100);
let start = Instant::now();
loop {
let mtime = std::fs::metadata(file)
.and_then(|m| m.modified())
.with_context(|| format!("failed to stat {}", file.display()))?;
let elapsed_since_edit = mtime.elapsed().unwrap_or(Duration::ZERO);
if elapsed_since_edit >= debounce {
eprintln!(
"[route] debounce OK — file idle for {:.1}s",
elapsed_since_edit.as_secs_f64()
);
return Ok(());
}
if start.elapsed() >= max_wait {
eprintln!(
"[route] debounce timeout after {:.1}s — proceeding anyway",
start.elapsed().as_secs_f64()
);
return Ok(());
}
std::thread::sleep(poll_interval);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_first_column_empty_cols() {
let file = Path::new("tasks/agent-doc.md");
assert!(!is_first_column(file, &[]));
}
#[test]
fn is_first_column_single_col() {
let file = Path::new("tasks/agent-doc.md");
let cols = vec!["tasks/agent-doc.md".to_string()];
assert!(!is_first_column(file, &cols));
}
#[test]
fn is_first_column_in_first_col() {
let file = Path::new("tasks/agent-doc.md");
let cols = vec![
"tasks/agent-doc.md".to_string(),
"tasks/email.md".to_string(),
];
assert!(is_first_column(file, &cols));
}
#[test]
fn is_first_column_in_second_col() {
let file = Path::new("tasks/email.md");
let cols = vec![
"tasks/agent-doc.md".to_string(),
"tasks/email.md".to_string(),
];
assert!(!is_first_column(file, &cols));
}
#[test]
fn is_first_column_comma_separated() {
let file = Path::new("tasks/agent-doc.md");
let cols = vec![
"tasks/agent-doc.md,tasks/corky.md".to_string(),
"tasks/email.md".to_string(),
];
assert!(is_first_column(file, &cols));
}
#[test]
fn detects_unicode_prompt() {
assert!(is_prompt_line("❯"));
assert!(is_prompt_line("❯ "));
assert!(is_prompt_line(" ❯ "));
}
#[test]
fn detects_ascii_prompt() {
assert!(is_prompt_line(">"));
assert!(is_prompt_line("> "));
assert!(is_prompt_line(" > "));
}
#[test]
fn rejects_non_prompt_lines() {
assert!(!is_prompt_line("Starting claude..."));
assert!(!is_prompt_line("test result: ok"));
assert!(!is_prompt_line(""));
assert!(!is_prompt_line(" "));
assert!(!is_prompt_line("## User"));
}
#[test]
fn handles_ansi_prompt() {
assert!(is_prompt_line("\x1b[32m❯\x1b[0m"));
assert!(is_prompt_line("\x1b[1m>\x1b[0m"));
}
fn is_prompt_line(line: &str) -> bool {
let stripped = prompt::strip_ansi(line);
let trimmed = stripped.trim();
trimmed == "❯"
|| trimmed == ">"
|| trimmed.starts_with("❯ ")
|| trimmed.starts_with("> ")
}
#[test]
fn unregistered_file_skips_lazy_claim() {
let registered: Option<String> = None;
assert!(
registered.is_none(),
"unregistered files should not attempt lazy claim"
);
}
#[test]
fn dead_registered_pane_allows_lazy_claim() {
let registered: Option<String> = Some("%99".to_string());
assert!(
registered.is_some(),
"dead registered pane should attempt lazy claim"
);
}
use sessions::IsolatedTmux;
fn mock_claude_script(delay_ms: u64) -> String {
format!(
r#"PS1='$ '; echo "Starting claude..."; sleep {}; echo '❯ '; cat"#,
delay_ms as f64 / 1000.0
)
}
#[test]
fn wait_for_claude_ready_detects_prompt() {
let iso = IsolatedTmux::new("route-test-ready");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
iso.send_keys(&pane, &mock_claude_script(500)).unwrap();
let ready = wait_for_claude_ready(&iso, &pane, std::time::Duration::from_secs(5));
assert!(ready, "should detect ❯ prompt from mock Claude");
}
#[test]
fn wait_for_claude_ready_times_out_without_prompt() {
let iso = IsolatedTmux::new("route-test-timeout");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_id = iso
.cmd()
.args(["new-session", "-d", "-s", session, "-c", &cwd.to_string_lossy(), "-P", "-F", "#{pane_id}", "sleep", "30"])
.output()
.expect("failed to create tmux session");
let pane = String::from_utf8_lossy(&pane_id.stdout).trim().to_string();
let ready = wait_for_claude_ready(&iso, &pane, std::time::Duration::from_secs(2));
assert!(!ready, "should time out when no ❯ prompt appears");
}
#[test]
fn send_keys_delivers_command_with_enter() {
let iso = IsolatedTmux::new("route-test-send");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
iso.send_keys(&pane, r#"read CMD && echo "GOT:$CMD""#).unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
iso.send_keys(&pane, "/agent-doc test.md").unwrap();
std::thread::sleep(std::time::Duration::from_millis(800));
let content = sessions::capture_pane(&iso, &pane).unwrap();
assert!(
content.contains("GOT:/agent-doc test.md"),
"command should be delivered and echoed back, got: {}",
content
);
}
#[test]
fn pane_has_prompt_detects_unicode() {
let iso = IsolatedTmux::new("route-test-has-prompt");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
iso.send_keys(&pane, "exec bash -c 'echo ❯; cat'").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1500));
let content = sessions::capture_pane(&iso, &pane).unwrap_or_default();
assert!(
pane_has_prompt(&iso, &pane),
"should detect ❯ in pane content, got: {}",
content
);
}
#[test]
fn full_auto_start_flow() {
let iso = IsolatedTmux::new("route-test-e2e");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
iso.send_keys(&pane, &mock_claude_script(300)).unwrap();
let ready = wait_for_claude_ready(&iso, &pane, std::time::Duration::from_secs(5));
assert!(ready, "mock Claude should become ready");
iso.send_keys(&pane, "HELLO_FROM_TEST").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let content = sessions::capture_pane(&iso, &pane).unwrap();
assert!(
content.contains("HELLO_FROM_TEST"),
"command should appear in pane after send, got: {}",
content
);
}
#[test]
fn select_pane_switches_window() {
let iso = IsolatedTmux::new("route-test-select");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start(session, &cwd).unwrap();
let output = iso
.cmd()
.args(["new-window", "-t", session, "-P", "-F", "#{pane_id}"])
.output()
.unwrap();
let pane2 = String::from_utf8_lossy(&output.stdout).trim().to_string();
iso.select_pane(&pane1).unwrap();
let active = iso
.cmd()
.args([
"display-message",
"-t",
session,
"-p",
"#{pane_id}",
])
.output()
.unwrap();
let active_pane = String::from_utf8_lossy(&active.stdout).trim().to_string();
assert_eq!(
active_pane, pane1,
"select_pane should switch to the correct window/pane"
);
let _ = pane2; }
#[test]
fn command_text_cleared_after_acceptance() {
let iso = IsolatedTmux::new("route-test-cmd-clear");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
iso.send_keys(&pane, "echo DONE").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let content = sessions::capture_pane(&iso, &pane).unwrap();
let _cmd_in_last_lines = content
.lines()
.rev()
.take(5)
.any(|l| l.contains("echo DONE") && !l.contains("DONE"));
assert!(
content.contains("DONE"),
"command should have been executed, got: {}",
content
);
}
#[test]
fn pane_session_detection() {
let iso = IsolatedTmux::new("route-test-session");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
let output = iso
.cmd()
.args(["display-message", "-t", &pane, "-p", "#{session_name}"])
.output()
.unwrap();
let detected_session = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
detected_session, session,
"pane should be in session '{}'", session
);
}
#[test]
fn pane_in_wrong_session_detected() {
let iso = IsolatedTmux::new("route-test-wrong-sess");
let cwd = std::env::current_dir().unwrap();
let correct_pane = iso.auto_start("correct", &cwd).unwrap();
let wrong_pane = iso.auto_start("wrong", &cwd).unwrap();
let correct_session = iso
.cmd()
.args(["display-message", "-t", &correct_pane, "-p", "#{session_name}"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap();
let wrong_session = iso
.cmd()
.args(["display-message", "-t", &wrong_pane, "-p", "#{session_name}"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap();
assert_eq!(correct_session, "correct");
assert_eq!(wrong_session, "wrong");
assert_ne!(correct_session, wrong_session, "panes should be in different sessions");
}
#[test]
fn auto_start_splits_in_existing_window() {
let iso = IsolatedTmux::new("route-test-split-existing");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start(session, &cwd).unwrap();
let window1 = iso.pane_window(&pane1).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
let window2 = iso.pane_window(&pane2).unwrap();
assert_eq!(
window1, window2,
"split_window should create pane in the SAME window, not a new one"
);
assert!(iso.pane_alive(&pane1));
assert!(iso.pane_alive(&pane2));
assert_ne!(pane1, pane2, "should create a distinct new pane");
}
#[test]
fn auto_start_creates_new_window_when_no_registered_panes() {
let iso = IsolatedTmux::new("route-test-new-window");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start(session, &cwd).unwrap();
let window1 = iso.pane_window(&pane1).unwrap();
let pane2 = iso.auto_start(session, &cwd).unwrap();
let window2 = iso.pane_window(&pane2).unwrap();
assert_ne!(
window1, window2,
"auto_start should create a new window when no registered panes exist"
);
assert_ne!(pane1, pane2);
}
#[test]
fn find_registered_pane_filters_by_session() {
let iso = IsolatedTmux::new("route-test-find-reg");
let cwd = std::env::current_dir().unwrap();
let pane_a = iso.auto_start("session-a", &cwd).unwrap();
let pane_b = iso.auto_start("session-b", &cwd).unwrap();
let sess_a = iso.pane_session(&pane_a).unwrap();
let sess_b = iso.pane_session(&pane_b).unwrap();
assert_eq!(sess_a, "session-a");
assert_eq!(sess_b, "session-b");
assert_ne!(pane_a, pane_b);
}
#[test]
fn split_window_respects_working_directory() {
let iso = IsolatedTmux::new("route-test-split-cwd");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start(session, &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
assert!(iso.pane_alive(&pane1));
assert!(iso.pane_alive(&pane2));
let w1 = iso.pane_window(&pane1).unwrap();
let w2 = iso.pane_window(&pane2).unwrap();
assert_eq!(w1, w2, "split pane should be in same window");
let panes = iso.list_window_panes(&w1).unwrap();
assert_eq!(panes.len(), 2, "window should have exactly 2 panes after split");
}
#[test]
fn stash_pane_on_split_failure() {
let iso = IsolatedTmux::new("route-test-stash-fallback");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start(session, &cwd).unwrap();
let fallback_pane = iso.auto_start(session, &cwd).unwrap();
let w1 = iso.pane_window(&pane).unwrap();
let w_fb_before = iso.pane_window(&fallback_pane).unwrap();
assert_ne!(w1, w_fb_before, "fallback should be in a new window initially");
iso.stash_pane(&fallback_pane, session).unwrap();
assert!(iso.pane_alive(&fallback_pane), "pane should still be alive");
let stash_win = iso.find_stash_window(session);
assert!(stash_win.is_some(), "stash window should have been created");
let w_fb_after = iso.pane_window(&fallback_pane).unwrap();
assert_eq!(
w_fb_after,
stash_win.unwrap(),
"fallback pane should be in the stash window"
);
}
#[test]
fn has_named_window_detects_agent_doc_window() {
let iso = IsolatedTmux::new("route-test-named-win");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let _pane = iso.auto_start(session, &cwd).unwrap();
assert!(
!has_named_window(&iso, session, "agent-doc"),
"should not find 'agent-doc' window before renaming"
);
let _ = iso
.cmd()
.args(["rename-window", "-t", &format!("{}:", session), "agent-doc"])
.status();
assert!(
has_named_window(&iso, session, "agent-doc"),
"should find 'agent-doc' window after renaming"
);
}
#[test]
fn has_named_window_false_for_nonexistent_session() {
let iso = IsolatedTmux::new("route-test-named-win-no-sess");
assert!(
!has_named_window(&iso, "nonexistent", "agent-doc"),
"should return false for nonexistent session"
);
}
#[test]
fn else_branch_stashes_when_agent_doc_window_exists() {
let iso = IsolatedTmux::new("route-test-else-stash");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let existing_pane = iso.auto_start(session, &cwd).unwrap();
let _ = iso
.cmd()
.args(["rename-window", "-t", &format!("{}:", session), "agent-doc"])
.status();
let new_pane = iso.auto_start(session, &cwd).unwrap();
assert!(iso.pane_alive(&new_pane));
let existing_win = iso.pane_window(&existing_pane).unwrap();
let new_win_before = iso.pane_window(&new_pane).unwrap();
assert_ne!(existing_win, new_win_before);
iso.stash_pane(&new_pane, session).unwrap();
assert!(iso.pane_alive(&new_pane));
let stash_win = iso.find_stash_window(session);
assert!(stash_win.is_some(), "stash window should exist");
let new_win_after = iso.pane_window(&new_pane).unwrap();
assert_eq!(
new_win_after,
stash_win.unwrap(),
"new pane should be in stash window"
);
}
#[test]
fn route_warns_on_nonexistent_tmux_session() {
let iso = IsolatedTmux::new("route-test-warn-nonexist");
let cwd = std::env::current_dir().unwrap();
let _fallback_pane = iso.auto_start("claude", &cwd).unwrap();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(
&file,
"---\nagent_doc_session: test-uuid-1234\ntmux_session: ghost-session\n---\n## User\nHello\n",
)
.unwrap();
assert!(
!iso.session_exists("ghost-session"),
"ghost-session should not exist before route"
);
unsafe { std::env::set_var("AGENT_DOC_NO_AUTOSTART", "1"); }
let result = run_with_tmux(&file, &iso, None, 0, &[]);
unsafe { std::env::remove_var("AGENT_DOC_NO_AUTOSTART"); }
assert!(
!iso.session_exists("ghost-session"),
"ghost-session should NOT have been created by route"
);
assert!(result.is_err(), "should error with no autostart");
}
#[test]
fn route_falls_back_to_existing_session() {
let iso = IsolatedTmux::new("route-test-fallback-sess");
let cwd = std::env::current_dir().unwrap();
let fallback_pane = iso.auto_start("claude", &cwd).unwrap();
let fallback_session = iso.pane_session(&fallback_pane).unwrap();
assert_eq!(fallback_session, "claude");
assert!(!iso.session_exists("ghost-session"));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
std::fs::write(
&file,
"---\nagent_doc_session: fallback-uuid-5678\ntmux_session: ghost-session\n---\n## User\nHello\n",
)
.unwrap();
unsafe { std::env::set_var("AGENT_DOC_NO_AUTOSTART", "1"); }
let _result = run_with_tmux(&file, &iso, None, 0, &[]);
unsafe { std::env::remove_var("AGENT_DOC_NO_AUTOSTART"); }
assert!(
!iso.session_exists("ghost-session"),
"nonexistent session should never be created by route"
);
assert!(
iso.session_exists("claude"),
"fallback session should still be alive"
);
}
#[test]
fn pane_in_stash_rescued_to_agent_doc() {
let iso = IsolatedTmux::new("route-test-stash-rescue");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start(session, &cwd).unwrap();
let _ = iso
.cmd()
.args(["rename-window", "-t", &format!("{}:", session), "agent-doc"])
.status();
let stashed_pane = iso.auto_start(session, &cwd).unwrap();
iso.stash_pane(&stashed_pane, session).unwrap();
let stash_win = iso.find_stash_window(session);
assert!(stash_win.is_some(), "stash window should exist");
let pane_win = iso.pane_window(&stashed_pane).unwrap();
assert_eq!(pane_win, stash_win.unwrap(), "pane should be in stash");
let agent_doc_window = format!("{}:agent-doc", session);
let target_panes = iso.list_window_panes(&agent_doc_window).unwrap_or_default();
assert!(!target_panes.is_empty(), "agent-doc window should have panes");
if let Some(target) = target_panes.first() {
match iso.swap_pane(&stashed_pane, target) {
Ok(()) => {
let _rescued_win = iso.pane_window(&stashed_pane).unwrap();
let _agent_doc_win_id = iso.pane_window(&pane1).unwrap_or_default();
assert!(iso.pane_alive(&stashed_pane), "rescued pane should be alive");
}
Err(_e) => {
iso.join_pane(&stashed_pane, target, "-dh").unwrap();
assert!(iso.pane_alive(&stashed_pane), "pane should survive join rescue");
}
}
}
}
#[test]
fn swap_failure_falls_back_to_join_pane() {
let iso = IsolatedTmux::new("route-test-swap-fallback");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let _pane1 = iso.auto_start(session, &cwd).unwrap();
let _ = iso
.cmd()
.args(["rename-window", "-t", &format!("{}:", session), "agent-doc"])
.status();
let pane2 = iso.auto_start(session, &cwd).unwrap();
let _win_before = iso.pane_window(&pane2).unwrap();
let agent_doc_window = format!("{}:agent-doc", session);
let target_panes = iso.list_window_panes(&agent_doc_window).unwrap();
let target = &target_panes[0];
iso.join_pane(&pane2, target, "-dh").unwrap();
let _win_after = iso.pane_window(&pane2).unwrap();
let agent_doc_panes = iso.list_window_panes(&agent_doc_window).unwrap();
assert!(
agent_doc_panes.contains(&pane2),
"pane should be in agent-doc window after join, got: {:?}",
agent_doc_panes
);
assert!(iso.pane_alive(&pane2), "pane should be alive after join");
}
#[test]
fn sync_after_claim_prefers_col_args_over_registry() {
let iso = IsolatedTmux::new("route-test-col-args");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_a = iso.auto_start(session, &cwd).unwrap();
let window_id = iso.pane_window(&pane_a).unwrap();
sync_after_claim(&iso, &pane_a, &["single.md".to_string()]);
sync_after_claim(&iso, &pane_a, &[]);
assert!(iso.pane_alive(&pane_a), "pane should survive sync_after_claim");
assert_eq!(
iso.pane_window(&pane_a).unwrap(),
window_id,
"pane should stay in original window"
);
let col_args = vec!["file_a.md".to_string(), "file_b.md".to_string()];
sync_after_claim(&iso, &pane_a, &col_args);
assert!(iso.pane_alive(&pane_a), "pane should survive sync with unresolved files");
}
#[test]
fn split_before_true_picks_leftmost_pane() {
let iso = IsolatedTmux::new("route-test-split-before-left");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_left = iso.auto_start(session, &cwd).unwrap();
let window = iso.pane_window(&pane_left).unwrap();
let _ = iso.raw_cmd(&["resize-window", "-t", &window, "-x", "300", "-y", "60"]);
let pane_right = iso.split_window(&pane_left, &cwd, "-dh").unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", &window, "agent-doc"]);
let ordered = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(ordered.len(), 2, "should have 2 panes");
assert_eq!(ordered[0], pane_left, "first pane should be leftmost");
assert_eq!(ordered[1], pane_right, "second pane should be rightmost");
let new_pane = iso.split_window(&ordered[0], &cwd, "-dbh").unwrap();
let new_window = iso.pane_window(&new_pane).unwrap();
assert_eq!(
iso.pane_window(&pane_left).unwrap(),
new_window,
"new pane should be in the same window as the leftmost pane"
);
let final_order = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(final_order.len(), 3, "should have 3 panes now");
assert_eq!(
final_order[0], new_pane,
"new pane should be leftmost (split before)"
);
}
#[test]
fn split_before_false_picks_rightmost_pane() {
let iso = IsolatedTmux::new("route-test-split-before-right");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_left = iso.auto_start(session, &cwd).unwrap();
let window = iso.pane_window(&pane_left).unwrap();
let _ = iso.raw_cmd(&["resize-window", "-t", &window, "-x", "300", "-y", "60"]);
let pane_right = iso.split_window(&pane_left, &cwd, "-dh").unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", &window, "agent-doc"]);
let ordered = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(ordered.len(), 2);
assert_eq!(ordered[0], pane_left);
assert_eq!(ordered[1], pane_right);
let new_pane = iso.split_window(&ordered[1], &cwd, "-dh").unwrap();
let new_window = iso.pane_window(&new_pane).unwrap();
assert_eq!(
iso.pane_window(&pane_right).unwrap(),
new_window,
"new pane should be in the same window as the rightmost pane"
);
let final_order = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(final_order.len(), 3, "should have 3 panes now");
assert_eq!(
final_order[2], new_pane,
"new pane should be rightmost (split after)"
);
}
#[test]
fn provision_pane_first_col_splits_left() {
let iso = IsolatedTmux::new("route-test-auto-start-col-left");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_left = iso.auto_start(session, &cwd).unwrap();
let window = iso.pane_window(&pane_left).unwrap();
let _ = iso.raw_cmd(&["resize-window", "-t", &window, "-x", "300", "-y", "60"]);
let pane_right = iso.split_window(&pane_left, &cwd, "-dh").unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", &window, "agent-doc"]);
let ordered = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(ordered.len(), 2, "should start with 2 panes");
let col_args = vec![
"tasks/file_a.md".to_string(),
"tasks/file_b.md".to_string(),
];
let file_a = Path::new("tasks/file_a.md");
let result = provision_pane(
&iso, file_a, "session-a", "tasks/file_a.md",
Some(session), &col_args,
);
assert!(result.is_ok(), "provision_pane should succeed: {:?}", result.err());
let after = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(after.len(), 3, "should have 3 panes after auto_start");
let new_pane: Vec<_> = after.iter()
.filter(|p| *p != &pane_left && *p != &pane_right)
.collect();
assert_eq!(new_pane.len(), 1, "should have exactly 1 new pane");
assert_eq!(
&after[0], new_pane[0],
"first-column file should produce leftmost pane (split_before=true)"
);
}
#[test]
fn provision_pane_second_col_splits_right() {
let iso = IsolatedTmux::new("route-test-auto-start-col-right");
let session = "test";
let cwd = std::env::current_dir().unwrap();
let pane_left = iso.auto_start(session, &cwd).unwrap();
let window = iso.pane_window(&pane_left).unwrap();
let _ = iso.raw_cmd(&["resize-window", "-t", &window, "-x", "300", "-y", "60"]);
let pane_right = iso.split_window(&pane_left, &cwd, "-dh").unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", &window, "agent-doc"]);
let ordered = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(ordered.len(), 2, "should start with 2 panes");
let col_args = vec![
"tasks/file_a.md".to_string(),
"tasks/file_b.md".to_string(),
];
let file_b = Path::new("tasks/file_b.md");
let result = provision_pane(
&iso, file_b, "session-b", "tasks/file_b.md",
Some(session), &col_args,
);
assert!(result.is_ok(), "provision_pane should succeed: {:?}", result.err());
let after = iso.list_window_panes(&format!("{}:agent-doc", session)).unwrap();
assert_eq!(after.len(), 3, "should have 3 panes after auto_start");
let new_pane: Vec<_> = after.iter()
.filter(|p| *p != &pane_left && *p != &pane_right)
.collect();
assert_eq!(new_pane.len(), 1, "should have exactly 1 new pane");
assert_eq!(
after.last().unwrap(), new_pane[0],
"second-column file should produce rightmost pane (split_before=false)"
);
}
#[test]
fn sync_after_claim_handles_malformed_registry() {
let iso = IsolatedTmux::new("route-test-malformed-registry");
let tmp = tempfile::TempDir::new().unwrap();
let session = "test";
let pane = iso.new_session(session, tmp.path()).unwrap();
let sessions_path = tmp.path().join(".agent-doc");
std::fs::create_dir_all(&sessions_path).unwrap();
std::fs::write(
sessions_path.join("sessions.json"),
r#"{"sessions": [{"bad": "format"}]}"#,
).unwrap();
sync_after_claim(&iso, &pane, &[]);
}
#[test]
fn sync_after_claim_with_empty_col_args_and_no_registry() {
let iso = IsolatedTmux::new("route-test-no-registry");
let tmp = tempfile::TempDir::new().unwrap();
let session = "test";
let pane = iso.new_session(session, tmp.path()).unwrap();
let window = iso.pane_window(&pane).unwrap();
let before = iso.list_window_panes(&window).unwrap();
sync_after_claim(&iso, &pane, &[]);
let after = iso.list_window_panes(&window).unwrap();
assert_eq!(before.len(), after.len(), "no panes should be created when no registry exists");
}
}