use anyhow::Result;
use crate::{config, frontmatter};
use crate::sessions::{self, PaneMoveOp, Tmux};
const AGENT_PROCESSES: &[&str] = &["agent-doc", "claude", "node"];
const IDLE_SHELLS: &[&str] = &["zsh", "bash", "sh", "fish"];
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
enum Issue {
WrongSession {
key: String,
file: String,
pane: String,
actual_session: String,
expected_session: String,
},
WrongProcess {
key: String,
file: String,
pane: String,
process: String,
},
WrongWindow {
file: String,
pane: String,
actual_window: String,
expected_window: String,
},
InStash {
key: String,
file: String,
pane: String,
window_name: String,
},
}
impl std::fmt::Display for Issue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Issue::WrongSession {
file,
pane,
actual_session,
expected_session,
..
} => write!(
f,
"{} (pane {}) in session '{}', expected '{}'",
file, pane, actual_session, expected_session
),
Issue::WrongProcess {
file,
pane,
process,
..
} => write!(
f,
"{} (pane {}) running '{}', expected agent-doc/claude",
file, pane, process
),
Issue::WrongWindow {
file,
pane,
actual_window,
expected_window,
..
} => write!(
f,
"{} (pane {}) in window '{}', expected '{}'",
file, pane, actual_window, expected_window
),
Issue::InStash {
file,
pane,
window_name,
..
} => write!(
f,
"{} (pane {}) is in stash window '{}'",
file, pane, window_name
),
}
}
}
pub fn prune() -> Result<usize> {
tracing::debug!("resync::prune start");
let tmux = Tmux::default_server();
let registry_path = sessions::registry_path();
let removed = tmux_router::prune(®istry_path, &tmux)?;
if removed > 0 {
tracing::debug!(removed, "resync: pruned stale sessions");
eprintln!("resync: pruned {} stale session(s)", removed);
}
let windows = fetch_all_window_metadata(&tmux);
let panes = fetch_all_pane_metadata(&tmux);
purge_stash_windows_bulk(&tmux, &windows, &panes);
purge_unregistered_stash_panes_bulk(&tmux, &windows, &panes);
Ok(removed)
}
fn purge_stash_windows(tmux: &Tmux) {
let output = tmux
.cmd()
.args([
"list-windows",
"-a",
"-F",
"#{window_id}\t#{window_name}\t#{window_activity}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() < 3 {
continue;
}
let (window_id, window_name, activity_str) = (parts[0], parts[1], parts[2]);
if window_name != "stash" {
continue;
}
if let Ok(activity) = activity_str.parse::<u64>()
&& now.saturating_sub(activity) < 30
{
continue;
}
let pane_output = tmux
.cmd()
.args([
"list-panes",
"-t",
window_id,
"-F",
"#{pane_current_command}",
])
.output();
let pane_output = match pane_output {
Ok(o) if o.status.success() => o,
_ => continue,
};
let all_idle = String::from_utf8_lossy(&pane_output.stdout)
.lines()
.all(|cmd| IDLE_SHELLS.contains(&cmd));
if all_idle {
if let Err(e) = tmux
.cmd()
.args(["kill-window", "-t", window_id])
.output()
{
eprintln!("resync: failed to purge stash window {}: {}", window_id, e);
} else {
eprintln!("resync: purged stash window {} (all panes idle)", window_id);
}
}
}
}
fn purge_unregistered_stash_panes(tmux: &Tmux) {
let registry = sessions::load().unwrap_or_default();
purge_unregistered_stash_panes_with_registry(tmux, ®istry);
}
fn purge_unregistered_stash_panes_with_registry(tmux: &Tmux, registry: &sessions::SessionRegistry) {
let registered_panes: std::collections::HashSet<&str> = registry
.values()
.map(|e| e.pane.as_str())
.collect();
let output = tmux
.cmd()
.args([
"list-windows",
"-a",
"-F",
"#{window_id}\t#{window_name}\t#{session_name}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
};
let mut killed_count = 0;
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() < 3 {
continue;
}
let (window_id, window_name, session_name) = (parts[0], parts[1], parts[2]);
if !is_stash_window_name(window_name) {
continue;
}
let panes = tmux.list_window_panes(window_id).unwrap_or_default();
if panes.is_empty() {
continue;
}
let mut panes_to_kill = Vec::new();
for pane_id in &panes {
if registered_panes.contains(pane_id.as_str()) {
continue; }
let cmd = pane_current_command(tmux, pane_id).unwrap_or_default();
if IDLE_SHELLS.contains(&cmd.as_str()) {
panes_to_kill.push(pane_id.clone());
} else if AGENT_PROCESSES.contains(&cmd.as_str()) {
eprintln!(
"resync: stash pane {} ({}) running '{}' is unregistered — skipping kill (may be rescuable)",
pane_id, session_name, cmd
);
}
}
for pane_id in &panes_to_kill {
if let Err(e) = tmux.kill_pane(pane_id) {
eprintln!("resync: failed to kill stash pane {}: {}", pane_id, e);
} else {
killed_count += 1;
}
}
let remaining = panes.len() - panes_to_kill.len();
if remaining > 0 && !panes_to_kill.is_empty() {
eprintln!(
"resync: purged {} of {} panes from stash {} in session '{}' ({} user-process panes remain)",
panes_to_kill.len(), panes.len(), window_id, session_name, remaining
);
}
}
if killed_count > 0 {
eprintln!("resync: purged {} orphaned stash pane(s)", killed_count);
}
}
fn return_stashed_panes(tmux: &Tmux) {
let registry = sessions::load().unwrap_or_default();
return_stashed_panes_with_registry(tmux, ®istry);
}
fn return_stashed_panes_with_registry(tmux: &Tmux, registry: &sessions::SessionRegistry) {
let pane_to_entry: std::collections::HashMap<&str, (&str, &sessions::SessionEntry)> = registry
.iter()
.map(|(k, e)| (e.pane.as_str(), (k.as_str(), e)))
.collect();
let output = tmux
.cmd()
.args([
"list-windows",
"-a",
"-F",
"#{window_id}\t#{window_name}\t#{session_name}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
};
let mut returned = 0;
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() < 3 {
continue;
}
let (window_id, window_name, _session_name) = (parts[0], parts[1], parts[2]);
if !is_stash_window_name(window_name) {
continue;
}
let pane_output = tmux
.cmd()
.args([
"list-panes",
"-t",
window_id,
"-F",
"#{pane_id}\t#{pane_current_command}",
])
.output();
let pane_output = match pane_output {
Ok(o) if o.status.success() => o,
_ => continue,
};
for pane_line in String::from_utf8_lossy(&pane_output.stdout).lines() {
let pane_parts: Vec<&str> = pane_line.splitn(2, '\t').collect();
if pane_parts.len() < 2 {
continue;
}
let (pane_id, pane_cmd) = (pane_parts[0], pane_parts[1]);
if IDLE_SHELLS.contains(&pane_cmd) {
continue;
}
let (key, entry) = match pane_to_entry.get(pane_id) {
Some(pair) => *pair,
None => continue, };
let target = find_return_target(tmux, entry);
let target = match target {
Some(t) => t,
None => {
eprintln!(
"resync: cannot return stashed pane {} ({}): no valid target found",
pane_id, key
);
continue;
}
};
match PaneMoveOp::new(tmux, pane_id, &target).join("-dv") {
Ok(()) => {
eprintln!(
"resync: returned stashed pane {} ({}, running '{}') to window {}",
pane_id, key, pane_cmd, target
);
returned += 1;
}
Err(e) => {
eprintln!(
"resync: failed to return stashed pane {} to {}: {}",
pane_id, target, e
);
}
}
}
}
if returned > 0 {
eprintln!("resync: returned {} stashed pane(s) to their sessions", returned);
}
}
type WindowMeta = Vec<(String, String, String, String)>; type PaneMeta = std::collections::HashMap<String, (String, String, String)>;
fn fetch_all_window_metadata(tmux: &Tmux) -> WindowMeta {
let output = tmux
.cmd()
.args([
"list-windows", "-a", "-F",
"#{window_id}\t#{window_name}\t#{session_name}\t#{window_activity}",
])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() >= 4 {
Some((
parts[0].to_string(), parts[1].to_string(),
parts[2].to_string(), parts[3].to_string(),
))
} else {
None
}
})
.collect()
}
_ => Vec::new(),
}
}
fn fetch_all_pane_metadata(tmux: &Tmux) -> PaneMeta {
let output = tmux
.cmd()
.args([
"list-panes", "-a", "-F",
"#{pane_id}\t#{window_id}\t#{window_name}\t#{pane_current_command}",
])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() >= 4 {
Some((
parts[0].to_string(),
(parts[1].to_string(), parts[2].to_string(), parts[3].to_string()),
))
} else {
None
}
})
.collect()
}
_ => std::collections::HashMap::new(),
}
}
fn purge_stash_windows_bulk(
tmux: &Tmux,
windows: &WindowMeta,
panes: &PaneMeta,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for (window_id, window_name, _session_name, activity_str) in windows {
if window_name != "stash" {
continue;
}
if let Ok(activity) = activity_str.parse::<u64>()
&& now.saturating_sub(activity) < 30
{
continue;
}
let all_idle = panes
.iter()
.filter(|(_, (wid, _, _))| wid == window_id)
.all(|(_, (_, _, cmd))| IDLE_SHELLS.contains(&cmd.as_str()));
let has_panes = panes.iter().any(|(_, (wid, _, _))| wid == window_id);
if has_panes && all_idle {
if let Err(e) = tmux
.cmd()
.args(["kill-window", "-t", window_id])
.output()
{
eprintln!("resync: failed to purge stash window {}: {}", window_id, e);
} else {
eprintln!("resync: purged stash window {} (all panes idle)", window_id);
}
}
}
}
fn purge_unregistered_stash_panes_bulk(
tmux: &Tmux,
windows: &WindowMeta,
panes: &PaneMeta,
) {
let registry = sessions::load().unwrap_or_default();
let registered_panes: std::collections::HashSet<&str> = registry
.values()
.map(|e| e.pane.as_str())
.collect();
let mut killed_count = 0;
let stash_windows: std::collections::HashSet<&str> = windows
.iter()
.filter(|(_, wname, _, _)| is_stash_window_name(wname))
.map(|(wid, _, _, _)| wid.as_str())
.collect();
for (pane_id, (window_id, _window_name, cmd)) in panes {
if !stash_windows.contains(window_id.as_str()) {
continue;
}
if registered_panes.contains(pane_id.as_str()) {
continue;
}
if IDLE_SHELLS.contains(&cmd.as_str()) {
if let Err(e) = tmux.kill_pane(pane_id) {
eprintln!("resync: failed to kill stash pane {}: {}", pane_id, e);
} else {
killed_count += 1;
}
} else if AGENT_PROCESSES.contains(&cmd.as_str()) {
eprintln!(
"resync: stash pane {} running '{}' is unregistered — skipping kill (may be rescuable)",
pane_id, cmd
);
}
}
if killed_count > 0 {
eprintln!("resync: purged {} orphaned stash pane(s)", killed_count);
}
}
#[allow(dead_code)]
fn return_stashed_panes_bulk(
tmux: &Tmux,
windows: &WindowMeta,
panes: &PaneMeta,
) {
let registry = sessions::load().unwrap_or_default();
let pane_to_entry: std::collections::HashMap<&str, (&str, &sessions::SessionEntry)> = registry
.iter()
.map(|(k, e)| (e.pane.as_str(), (k.as_str(), e)))
.collect();
let stash_windows: std::collections::HashSet<&str> = windows
.iter()
.filter(|(_, wname, _, _)| is_stash_window_name(wname))
.map(|(wid, _, _, _)| wid.as_str())
.collect();
let mut returned = 0;
let mut deregistered = Vec::new();
for (pane_id, (window_id, _window_name, cmd)) in panes {
if !stash_windows.contains(window_id.as_str()) {
continue;
}
if IDLE_SHELLS.contains(&cmd.as_str()) {
continue;
}
let (key, entry) = match pane_to_entry.get(pane_id.as_str()) {
Some(pair) => *pair,
None => continue,
};
let target = find_return_target_bulk(entry, windows, panes);
let target = match target {
Some(t) => t,
None => {
if IDLE_SHELLS.contains(&cmd.as_str()) {
eprintln!(
"resync: cannot return stashed pane {} ({}): no valid target found — deregistering idle shell",
pane_id, key
);
deregistered.push(key.to_string());
} else {
eprintln!(
"resync: cannot return stashed pane {} ({}): no valid target found — keeping registered (running '{}')",
pane_id, key, cmd
);
}
continue;
}
};
match PaneMoveOp::new(tmux, pane_id, &target).join("-dv") {
Ok(()) => {
eprintln!(
"resync: returned stashed pane {} ({}, running '{}') to window {}",
pane_id, key, cmd, target
);
returned += 1;
}
Err(e) => {
eprintln!(
"resync: failed to return stashed pane {} to {}: {}",
pane_id, target, e
);
}
}
}
if !deregistered.is_empty()
&& let Ok(mut reg) = sessions::load()
{
for key in &deregistered {
reg.remove(key);
}
if let Err(e) = sessions::save(®) {
eprintln!("resync: failed to save registry after deregister: {}", e);
} else {
eprintln!("resync: deregistered {} stranded pane(s)", deregistered.len());
}
}
if returned > 0 {
eprintln!("resync: returned {} stashed pane(s) to their sessions", returned);
}
}
#[allow(dead_code)]
fn find_return_target_bulk(
entry: &sessions::SessionEntry,
windows: &WindowMeta,
panes: &PaneMeta,
) -> Option<String> {
if !entry.window.is_empty() {
let window_panes: Vec<&String> = panes
.iter()
.filter(|(_, (wid, _, _))| wid == &entry.window)
.map(|(pid, _)| pid)
.collect();
if !window_panes.is_empty()
&& let Some((_, wname, _)) = panes.get(window_panes[0])
&& !is_stash_window_name(wname)
{
return Some(window_panes[0].clone());
}
}
let session_name = if !entry.file.is_empty() {
std::fs::read_to_string(&entry.file)
.ok()
.and_then(|content| {
let (fm, _) = frontmatter::parse(&content).ok()?;
fm.tmux_session
})
} else {
None
};
if let Some(ref sess) = session_name {
for (window_id, window_name, session, _) in windows {
if session == sess && !is_stash_window_name(window_name) {
if let Some((pid, _)) = panes.iter().find(|(_, (wid, _, _))| wid == window_id) {
return Some(pid.clone());
}
}
}
}
for (window_id, window_name, _session, _) in windows {
if !is_stash_window_name(window_name) && let Some((pid, _)) = panes.iter().find(|(_, (wid, _, _))| wid == window_id) {
return Some(pid.clone());
}
}
None
}
fn find_return_target(tmux: &Tmux, entry: &sessions::SessionEntry) -> Option<String> {
if !entry.window.is_empty()
&& let Ok(panes) = tmux.list_window_panes(&entry.window)
&& !panes.is_empty() {
if let Some(wname) = pane_window_name(tmux, &panes[0])
&& !is_stash_window_name(&wname) {
return Some(panes[0].clone());
}
}
let session_name = if !entry.file.is_empty() {
std::fs::read_to_string(&entry.file)
.ok()
.and_then(|content| {
let (fm, _) = frontmatter::parse(&content).ok()?;
fm.tmux_session
})
} else {
None
};
if let Some(ref sess) = session_name
&& tmux.session_exists(sess)
&& let Some(target) = first_non_stash_pane(tmux, sess) {
return Some(target);
}
None
}
fn first_non_stash_pane(tmux: &Tmux, session_name: &str) -> Option<String> {
let output = tmux
.cmd()
.args([
"list-windows",
"-t",
&format!("{}:", session_name),
"-F",
"#{window_id}\t#{window_name}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.len() < 2 {
continue;
}
let (window_id, window_name) = (parts[0], parts[1]);
if is_stash_window_name(window_name) {
continue;
}
if let Ok(panes) = tmux.list_window_panes(window_id)
&& let Some(first) = panes.into_iter().next() {
return Some(first);
}
}
None
}
fn purge_orphaned_agent_panes(tmux: &Tmux) {
let registry = sessions::load().unwrap_or_default();
purge_orphaned_agent_panes_with_registry(tmux, ®istry);
}
fn purge_orphaned_agent_panes_with_registry(tmux: &Tmux, registry: &sessions::SessionRegistry) {
let registered_panes: std::collections::HashSet<&str> = registry
.values()
.map(|e| e.pane.as_str())
.collect();
let output = tmux
.cmd()
.args([
"list-panes",
"-a",
"-F",
"#{pane_id}\t#{window_id}\t#{pane_current_command}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
};
let mut window_panes: std::collections::HashMap<String, Vec<(String, String)>> =
std::collections::HashMap::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() < 3 {
continue;
}
let (pane_id, window_id, cmd) = (parts[0], parts[1], parts[2]);
window_panes
.entry(window_id.to_string())
.or_default()
.push((pane_id.to_string(), cmd.to_string()));
}
let mut killed = 0;
for panes in window_panes.values() {
if panes.len() < 2 {
continue; }
for (pane_id, cmd) in panes {
if registered_panes.contains(pane_id.as_str()) {
continue; }
if AGENT_PROCESSES.contains(&cmd.as_str()) {
if let Err(e) = tmux.kill_pane(pane_id) {
eprintln!("resync: failed to kill orphaned agent pane {}: {}", pane_id, e);
} else {
killed += 1;
}
}
}
}
if killed > 0 {
eprintln!("resync: purged {} orphaned agent pane(s) from non-stash windows", killed);
}
}
fn detect_issues(tmux: &Tmux) -> Vec<Issue> {
tracing::debug!("resync::detect_issues start");
let registry = match sessions::load() {
Ok(r) => r,
Err(e) => {
eprintln!("resync: failed to load registry: {}", e);
return Vec::new();
}
};
detect_issues_in_registry(tmux, ®istry)
}
struct PaneInfo {
label: String,
pane: String,
tmux_session: String,
window_id: String,
window_name: String,
}
fn detect_issues_in_registry(tmux: &Tmux, registry: &sessions::SessionRegistry) -> Vec<Issue> {
let mut issues = Vec::new();
let mut alive_panes: Vec<PaneInfo> = Vec::new();
for (key, entry) in registry {
if !tmux.pane_alive(&entry.pane) {
continue; }
let label = if entry.file.is_empty() {
key.as_str()
} else {
entry.file.as_str()
};
if let Some(ref wname) = pane_window_name(tmux, &entry.pane)
&& is_stash_window_name(wname)
{
issues.push(Issue::InStash {
key: key.clone(),
file: label.to_string(),
pane: entry.pane.clone(),
window_name: wname.clone(),
});
continue; }
let pane_cmd = pane_current_command(tmux, &entry.pane);
if let Some(ref cmd) = pane_cmd
&& !AGENT_PROCESSES.contains(&cmd.as_str())
&& !IDLE_SHELLS.contains(&cmd.as_str())
{
issues.push(Issue::WrongProcess {
key: key.clone(),
file: label.to_string(),
pane: entry.pane.clone(),
process: cmd.clone(),
});
continue; }
if entry.file.is_empty() {
continue; }
let frontmatter_session = match std::fs::read_to_string(&entry.file) {
Ok(content) => match frontmatter::parse(&content) {
Ok((fm, _)) => fm.tmux_session,
Err(_) => None,
},
Err(_) => None,
};
let expected_session = frontmatter_session.or_else(config::project_tmux_session);
if let Some(ref expected) = expected_session {
match tmux.pane_session(&entry.pane) {
Ok(actual) if actual != *expected => {
issues.push(Issue::WrongSession {
key: key.clone(),
file: label.to_string(),
pane: entry.pane.clone(),
actual_session: actual,
expected_session: expected.clone(),
});
}
Err(e) => {
eprintln!(
"resync: failed to query session for pane {}: {}",
entry.pane, e
);
}
_ => {} }
}
alive_panes.push(PaneInfo {
label: label.to_string(),
pane: entry.pane.clone(),
tmux_session: tmux.pane_session(&entry.pane).unwrap_or_default(),
window_id: tmux.pane_window(&entry.pane).unwrap_or_default(),
window_name: pane_window_name(tmux, &entry.pane).unwrap_or_default(),
});
}
let mut by_session: std::collections::HashMap<String, Vec<&PaneInfo>> =
std::collections::HashMap::new();
for info in &alive_panes {
if is_stash_window_name(&info.window_name) {
continue;
}
by_session
.entry(info.tmux_session.clone())
.or_default()
.push(info);
}
for panes in by_session.values() {
if panes.len() < 2 {
continue;
}
let mut window_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for p in panes {
*window_counts.entry(&p.window_id).or_insert(0) += 1;
}
let expected_window = window_counts
.iter()
.max_by_key(|(_, count)| *count)
.map(|(w, _)| *w)
.unwrap_or("");
for p in panes {
if p.window_id != expected_window {
issues.push(Issue::WrongWindow {
file: p.label.clone(),
pane: p.pane.clone(),
actual_window: p.window_id.clone(),
expected_window: expected_window.to_string(),
});
}
}
}
issues
}
fn pane_window_name(tmux: &Tmux, pane_id: &str) -> Option<String> {
let output = tmux
.cmd()
.args([
"display-message",
"-t",
pane_id,
"-p",
"#{window_name}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
fn is_stash_window_name(name: &str) -> bool {
name == "stash" || name.starts_with("stash-")
}
fn pane_current_command(tmux: &Tmux, pane_id: &str) -> Option<String> {
let output = tmux
.cmd()
.args([
"display-message",
"-t",
pane_id,
"-p",
"#{pane_current_command}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let cmd = String::from_utf8_lossy(&output.stdout).trim().to_string();
if cmd.is_empty() { None } else { Some(cmd) }
}
fn apply_fixes(tmux: &Tmux, issues: &[Issue], relocate_session: Option<&str>) -> Result<usize> {
if issues.is_empty() {
return Ok(0);
}
tracing::debug!(issue_count = issues.len(), "resync::apply_fixes");
let registry_path = sessions::registry_path();
let _lock = tmux_router::RegistryLock::acquire(®istry_path)?;
let mut registry = sessions::load()?;
let fixed = apply_fixes_to_registry(tmux, issues, &mut registry, relocate_session);
if fixed > 0 {
sessions::save(®istry)?;
}
Ok(fixed)
}
fn apply_fixes_to_registry(
tmux: &Tmux,
issues: &[Issue],
registry: &mut sessions::SessionRegistry,
relocate_session: Option<&str>,
) -> usize {
let mut fixed = 0;
for issue in issues {
match issue {
Issue::WrongSession { key, pane, expected_session, .. } => {
if let Some(target) = relocate_session {
let dest_session = if target == expected_session.as_str() {
expected_session.as_str()
} else {
target
};
if let Some(dest_pane) = tmux.active_pane(dest_session) {
match PaneMoveOp::new(tmux, pane, &dest_pane)
.allow_cross_session("relocate WrongSession pane to project session")
.join("-dh")
{
Ok(()) => eprintln!(" relocated pane {} → session '{}'", pane, dest_session),
Err(e) => {
eprintln!(" relocate failed for pane {} ({}), deregistering", pane, e);
registry.remove(key);
}
}
} else {
eprintln!(" no active pane in '{}' to join into, deregistering pane {}", dest_session, pane);
registry.remove(key);
}
} else {
if let Err(e) = tmux.kill_pane(pane) {
eprintln!("resync: could not kill pane {} ({}), deregistering anyway", pane, e);
}
registry.remove(key);
}
eprintln!(" fixed: {}", issue);
fixed += 1;
}
Issue::WrongProcess { key, .. } => {
registry.remove(key);
eprintln!(" fixed: {}", issue);
fixed += 1;
}
Issue::InStash { key, pane, .. } => {
eprintln!(" [resync] pane {} for {} is in stash window, deregistering", pane, key);
registry.remove(key);
fixed += 1;
}
Issue::WrongWindow { pane, .. } => {
let session_name = tmux
.pane_session(pane)
.unwrap_or_else(|_| "claude".to_string());
if let Err(e) = tmux.stash_pane(pane, &session_name) {
eprintln!(" resync: failed to stash pane {}: {}", pane, e);
continue;
}
eprintln!(" fixed: {}", issue);
fixed += 1;
}
}
}
fixed
}
pub fn run(fix: bool, relocate_session: Option<&str>) -> Result<()> {
let tmux = Tmux::default_server();
let registry_path = sessions::registry_path();
let registry_before = sessions::load()?;
let before = registry_before.len();
let removed = tmux_router::prune(®istry_path, &tmux)?;
if removed > 0 {
let registry_after = sessions::load()?;
eprintln!("Removed {} stale session(s):", removed);
for (key, entry) in ®istry_before {
if !registry_after.contains_key(key) {
let label = if entry.file.is_empty() {
key.as_str()
} else {
entry.file.as_str()
};
eprintln!(" {} (pane {} removed)", label, entry.pane);
}
}
} else {
eprintln!("All {} session(s) have live panes.", before);
}
let issues = detect_issues(&tmux);
if !issues.is_empty() {
if fix {
eprintln!("\nFixing {} issue(s):", issues.len());
let fixed = apply_fixes(&tmux, &issues, relocate_session)?;
eprintln!("\nFixed {} of {} issue(s).", fixed, issues.len());
} else {
eprintln!("\nFound {} issue(s) (run with --fix to resolve):", issues.len());
for issue in &issues {
eprintln!(" {}", issue);
}
}
} else {
eprintln!("\nNo session/process issues detected.");
}
if fix {
return_stashed_panes(&tmux);
purge_stash_windows(&tmux);
purge_unregistered_stash_panes(&tmux);
purge_orphaned_agent_panes(&tmux);
}
let registry = sessions::load()?;
if !registry.is_empty() {
eprintln!("\nActive sessions:");
for (key, entry) in ®istry {
let label = if entry.file.is_empty() {
key.as_str()
} else {
entry.file.as_str()
};
eprintln!(" {} -> pane {}", label, entry.pane);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sessions::{IsolatedTmux, SessionEntry, SessionRegistry};
fn test_entry(pane: &str, file: &str) -> SessionEntry {
SessionEntry {
pane: pane.to_string(),
pid: std::process::id(),
cwd: "/tmp".to_string(),
started: "2026-01-01T00:00:00Z".to_string(),
file: file.to_string(),
window: String::new(),
}
}
#[test]
fn detect_dead_pane_not_flagged_as_issue() {
let iso = IsolatedTmux::new("resync-test-dead");
let mut registry = SessionRegistry::new();
registry.insert("dead-session".to_string(), test_entry("%99999", "test.md"));
let issues = detect_issues_in_registry(&iso, ®istry);
assert!(
issues.is_empty(),
"dead panes should not generate issues (handled by prune), got: {:?}",
issues.iter().map(|i| i.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn detect_wrong_session_pane() {
let iso = IsolatedTmux::new("resync-test-wrong-sess");
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start("wrong", &cwd).unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let tmp = tempfile::TempDir::new().unwrap();
let doc_path = tmp.path().join("test.md");
std::fs::write(
&doc_path,
"---\nsession: abc-123\ntmux_session: correct\n---\n# Test\n",
)
.unwrap();
let mut registry = SessionRegistry::new();
registry.insert(
"abc-123".to_string(),
test_entry(&pane, &doc_path.to_string_lossy()),
);
let issues = detect_issues_in_registry(&iso, ®istry);
assert_eq!(issues.len(), 1, "should detect 1 wrong-session issue");
assert!(
matches!(&issues[0], Issue::WrongSession { expected_session, actual_session, .. }
if expected_session == "correct" && actual_session == "wrong"),
"issue should be WrongSession with correct vs wrong, got: {}",
&issues[0]
);
}
#[test]
fn detect_wrong_process_pane() {
let iso = IsolatedTmux::new("resync-test-wrong-proc");
let cwd = std::env::current_dir().unwrap();
let output = iso
.cmd()
.args([
"new-session",
"-d",
"-s",
"test",
"-c",
&cwd.to_string_lossy(),
"-P",
"-F",
"#{pane_id}",
"sleep",
"60",
])
.output()
.unwrap();
let pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut registry = SessionRegistry::new();
registry.insert("sess-1".to_string(), test_entry(&pane, "test.md"));
std::thread::sleep(std::time::Duration::from_millis(200));
let issues = detect_issues_in_registry(&iso, ®istry);
assert_eq!(issues.len(), 1, "should detect 1 wrong-process issue");
assert!(
matches!(&issues[0], Issue::WrongProcess { process, .. } if process == "sleep"),
"issue should be WrongProcess(sleep), got: {}",
&issues[0]
);
}
#[test]
fn fix_wrong_session_kills_pane_and_deregisters() {
let iso = IsolatedTmux::new("resync-test-fix-sess");
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start("wrong", &cwd).unwrap();
assert!(iso.pane_alive(&pane));
let _ = iso.new_window("wrong", &cwd);
let mut registry = SessionRegistry::new();
registry.insert("sess-fix".to_string(), test_entry(&pane, "test.md"));
let issues = vec![Issue::WrongSession {
key: "sess-fix".to_string(),
file: "test.md".to_string(),
pane: pane.clone(),
actual_session: "wrong".to_string(),
expected_session: "correct".to_string(),
}];
let fixed = apply_fixes_to_registry(&iso, &issues, &mut registry, None);
assert_eq!(fixed, 1);
assert!(!registry.contains_key("sess-fix"), "entry should be removed from registry");
assert!(!iso.pane_alive(&pane), "pane should be killed");
}
#[test]
fn fix_wrong_process_deregisters_but_keeps_pane() {
let iso = IsolatedTmux::new("resync-test-fix-proc");
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start("test", &cwd).unwrap();
assert!(iso.pane_alive(&pane));
let mut registry = SessionRegistry::new();
registry.insert("sess-proc".to_string(), test_entry(&pane, "test.md"));
let issues = vec![Issue::WrongProcess {
key: "sess-proc".to_string(),
file: "test.md".to_string(),
pane: pane.clone(),
process: "corky".to_string(),
}];
let fixed = apply_fixes_to_registry(&iso, &issues, &mut registry, None);
assert_eq!(fixed, 1);
assert!(!registry.contains_key("sess-proc"), "entry should be removed from registry");
assert!(iso.pane_alive(&pane), "pane should NOT be killed (foreign process)");
}
#[test]
fn no_fix_without_flag() {
let iso = IsolatedTmux::new("resync-test-no-fix");
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start("wrong", &cwd).unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let doc_path = tmp.path().join("test.md");
std::fs::write(
&doc_path,
"---\nsession: abc\ntmux_session: correct\n---\n",
)
.unwrap();
let mut registry = SessionRegistry::new();
registry.insert(
"abc".to_string(),
test_entry(&pane, &doc_path.to_string_lossy()),
);
let issues = detect_issues_in_registry(&iso, ®istry);
assert!(!issues.is_empty(), "should detect issues");
assert!(registry.contains_key("abc"), "registry should be unchanged");
assert!(iso.pane_alive(&pane), "pane should still be alive");
}
#[test]
fn healthy_pane_has_no_issues() {
let iso = IsolatedTmux::new("resync-test-healthy");
let cwd = std::env::current_dir().unwrap();
let pane = iso.auto_start("test", &cwd).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2000));
let mut registry = SessionRegistry::new();
registry.insert("healthy-sess".to_string(), test_entry(&pane, ""));
let issues = detect_issues_in_registry(&iso, ®istry);
assert!(
issues.is_empty(),
"healthy idle shell should have no issues, got: {:?}",
issues.iter().map(|i| i.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn detect_wrong_window_panes_in_different_windows() {
let iso = IsolatedTmux::new("resync-test-wrong-win");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.auto_start("test", &cwd).unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let w1 = iso.pane_window(&pane1).unwrap();
let w2 = iso.pane_window(&pane2).unwrap();
assert_ne!(w1, w2, "panes should be in different windows");
let mut registry = SessionRegistry::new();
registry.insert("sess-1".to_string(), test_entry(&pane1, "a.md"));
registry.insert("sess-2".to_string(), test_entry(&pane2, "b.md"));
let issues = detect_issues_in_registry(&iso, ®istry);
let wrong_window_count = issues
.iter()
.filter(|i| matches!(i, Issue::WrongWindow { .. }))
.count();
assert_eq!(
wrong_window_count, 1,
"should detect 1 wrong-window issue (minority pane), got issues: {:?}",
issues.iter().map(|i| i.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn no_wrong_window_when_panes_in_same_window() {
let iso = IsolatedTmux::new("resync-test-same-win");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let w1 = iso.pane_window(&pane1).unwrap();
let w2 = iso.pane_window(&pane2).unwrap();
assert_eq!(w1, w2, "panes should be in the same window");
let mut registry = SessionRegistry::new();
registry.insert("sess-1".to_string(), test_entry(&pane1, "a.md"));
registry.insert("sess-2".to_string(), test_entry(&pane2, "b.md"));
let issues = detect_issues_in_registry(&iso, ®istry);
let wrong_window_count = issues
.iter()
.filter(|i| matches!(i, Issue::WrongWindow { .. }))
.count();
assert_eq!(
wrong_window_count, 0,
"should not detect wrong-window when panes are in same window, got: {:?}",
issues.iter().map(|i| i.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn no_wrong_window_for_stash_panes() {
let iso = IsolatedTmux::new("resync-test-stash-excl");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.auto_start("test", &cwd).unwrap();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let mut registry = SessionRegistry::new();
registry.insert("sess-1".to_string(), test_entry(&pane1, "a.md"));
registry.insert("sess-2".to_string(), test_entry(&pane2, "b.md"));
let issues = detect_issues_in_registry(&iso, ®istry);
let wrong_window_count = issues
.iter()
.filter(|i| matches!(i, Issue::WrongWindow { .. }))
.count();
assert_eq!(
wrong_window_count, 0,
"stash panes should be excluded from wrong-window detection, got: {:?}",
issues.iter().map(|i| i.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn fix_wrong_window_stashes_pane() {
let iso = IsolatedTmux::new("resync-test-fix-win");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.auto_start("test", &cwd).unwrap();
let w1 = iso.pane_window(&pane1).unwrap();
let w2_before = iso.pane_window(&pane2).unwrap();
assert_ne!(w1, w2_before, "panes should start in different windows");
let mut registry = SessionRegistry::new();
registry.insert("sess-1".to_string(), test_entry(&pane1, "a.md"));
registry.insert("sess-2".to_string(), test_entry(&pane2, "b.md"));
let issues = vec![Issue::WrongWindow {
file: "b.md".to_string(),
pane: pane2.clone(),
actual_window: w2_before.clone(),
expected_window: w1.clone(),
}];
let fixed = apply_fixes_to_registry(&iso, &issues, &mut registry, None);
assert_eq!(fixed, 1);
assert!(iso.pane_alive(&pane2), "pane should still be alive (moved, not killed)");
let stash_win = iso.find_stash_window("test");
assert!(stash_win.is_some(), "stash window should exist");
let w2_after = iso.pane_window(&pane2).unwrap();
assert_eq!(
w2_after,
stash_win.unwrap(),
"pane should have been moved to stash window"
);
}
#[test]
fn purge_kills_unregistered_shell_in_stash() {
let iso = IsolatedTmux::new("resync-purge-shell");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(iso.pane_alive(&pane2), "pane2 should be alive in stash");
let registry = SessionRegistry::new();
purge_unregistered_stash_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(!iso.pane_alive(&pane2), "unregistered shell in stash should be killed");
}
#[test]
fn purge_preserves_registered_pane_in_stash() {
let iso = IsolatedTmux::new("resync-purge-registered");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let mut registry = SessionRegistry::new();
registry.insert("registered-sess".to_string(), test_entry(&pane2, "test.md"));
purge_unregistered_stash_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(iso.pane_alive(&pane2), "registered pane in stash should survive purge");
}
#[test]
fn purge_preserves_user_process_in_stash() {
let iso = IsolatedTmux::new("resync-purge-userproc");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let output = iso
.cmd()
.args([
"split-window",
"-t", &pane1,
"-d", "-h",
"-c", &cwd.to_string_lossy(),
"-P", "-F", "#{pane_id}",
"sleep", "60",
])
.output()
.unwrap();
let pane2 = String::from_utf8_lossy(&output.stdout).trim().to_string();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let registry = SessionRegistry::new();
purge_unregistered_stash_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(iso.pane_alive(&pane2), "user process (sleep) in stash should survive purge");
}
#[test]
fn purge_preserves_unregistered_agent_process_in_stash() {
let iso = IsolatedTmux::new("resync-purge-agent-stash");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let registry = SessionRegistry::new();
purge_unregistered_stash_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(!iso.pane_alive(&pane2), "unregistered idle shell in stash should still be killed");
}
#[test]
fn purge_orphan_agent_in_non_stash_window() {
let iso = IsolatedTmux::new("resync-purge-orphan-agent");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let registry = SessionRegistry::new();
purge_orphaned_agent_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(iso.pane_alive(&pane1), "shell pane1 should survive");
assert!(iso.pane_alive(&pane2), "shell pane2 should survive");
}
#[test]
fn purge_orphan_does_not_kill_last_pane() {
let iso = IsolatedTmux::new("resync-purge-last-pane");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let registry = SessionRegistry::new();
purge_orphaned_agent_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(iso.pane_alive(&pane1), "last pane in window should not be killed");
}
#[test]
fn is_stash_window_name_matches() {
assert!(is_stash_window_name("stash"));
assert!(is_stash_window_name("stash-1"));
assert!(is_stash_window_name("stash-42"));
assert!(!is_stash_window_name("claude"));
assert!(!is_stash_window_name(""));
assert!(!is_stash_window_name("stashed"));
}
#[test]
fn return_stashed_panes_moves_active_pane_back() {
let iso = IsolatedTmux::new("resync-return-active");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let output = iso
.cmd()
.args([
"split-window",
"-t", &pane1,
"-d", "-h",
"-c", &cwd.to_string_lossy(),
"-P", "-F", "#{pane_id}",
"sleep", "60",
])
.output()
.unwrap();
let active_pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
let original_window = iso.pane_window(&active_pane).unwrap();
iso.stash_pane(&active_pane, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let stash_window = iso.pane_window(&active_pane).unwrap();
assert_ne!(stash_window, original_window, "pane should be in stash");
let mut registry = SessionRegistry::new();
let mut entry = test_entry(&active_pane, "");
entry.window = original_window.clone();
registry.insert("active-sess".to_string(), entry);
return_stashed_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(iso.pane_alive(&active_pane), "pane should still be alive");
let current_window = iso.pane_window(&active_pane).unwrap();
assert_eq!(
current_window, original_window,
"active pane should be returned to original window"
);
}
#[test]
fn return_stashed_panes_skips_idle_shells() {
let iso = IsolatedTmux::new("resync-return-idle");
let cwd = std::env::current_dir().unwrap();
let pane1 = iso.auto_start("test", &cwd).unwrap();
let pane2 = iso.split_window(&pane1, &cwd, "-dh").unwrap();
let original_window = iso.pane_window(&pane2).unwrap();
iso.stash_pane(&pane2, "test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let stash_window = iso.pane_window(&pane2).unwrap();
assert_ne!(stash_window, original_window, "pane should be in stash");
let mut registry = SessionRegistry::new();
let mut entry = test_entry(&pane2, "");
entry.window = original_window.clone();
registry.insert("idle-sess".to_string(), entry);
return_stashed_panes_with_registry(&iso, ®istry);
std::thread::sleep(std::time::Duration::from_millis(300));
let current_window = iso.pane_window(&pane2).unwrap();
assert_eq!(
current_window, stash_window,
"idle shell should NOT be returned from stash"
);
}
}