use anyhow::Result;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use crate::sessions::{PaneMoveOp, Tmux};
use crate::{frontmatter, resync, route, sessions};
use tmux_router::FileResolution;
pub fn run(col_args: &[String], window: Option<&str>, focus: Option<&str>) -> Result<()> {
tracing::debug!(cols = ?col_args, window, focus, "sync::run start");
run_with_options(col_args, window, focus, true, &Tmux::default_server())
}
#[allow(dead_code)]
pub fn run_layout_only(col_args: &[String], window: Option<&str>, focus: Option<&str>) -> Result<()> {
run_with_options(col_args, window, focus, false, &Tmux::default_server())
}
pub fn run_with_tmux(
col_args: &[String],
window: Option<&str>,
focus: Option<&str>,
tmux: &Tmux,
) -> Result<()> {
run_with_options(col_args, window, focus, true, tmux)
}
pub fn repair_layout(tmux: &Tmux, session_name: &str, target_window_name: &str) -> Result<()> {
tracing::debug!(session_name, target_window_name, "sync::repair_layout start");
let output = tmux.raw_cmd(&[
"list-windows",
"-t",
&format!("{}:", session_name),
"-F",
"#{window_id} #{window_name} #{window_panes}",
]);
let window_list = match output {
Ok(s) => s,
Err(e) => {
eprintln!("[repair] failed to list windows for session {}: {}", session_name, e);
return Ok(());
}
};
struct WinInfo {
id: String,
name: String,
_pane_count: usize,
}
let windows: Vec<WinInfo> = window_list
.lines()
.filter_map(|line| {
let mut parts = line.splitn(3, ' ');
let id = parts.next()?.to_string();
let name = parts.next()?.to_string();
let pane_count: usize = parts.next()?.parse().ok()?;
Some(WinInfo { id, name, _pane_count: pane_count })
})
.collect();
let has_target = windows.iter().any(|w| w.name == target_window_name);
let stash_count = windows.iter().filter(|w| w.name == "stash" || w.name.starts_with("stash-")).count();
let skip_phase_1_2 = has_target && stash_count <= 1;
if skip_phase_1_2 {
} else {
eprintln!("[repair] layout needs repair: target={} stash_count={}", has_target, stash_count);
let primary_stash = windows.iter().find(|w| w.name == "stash");
let mut secondary_stash_ids: Vec<String> = Vec::new();
let mut seen_primary = false;
for w in &windows {
if w.name == "stash" {
if seen_primary {
secondary_stash_ids.push(w.id.clone());
}
seen_primary = true;
} else if w.name.starts_with("stash-") {
secondary_stash_ids.push(w.id.clone());
}
}
if !secondary_stash_ids.is_empty() {
let primary_id = if let Some(p) = primary_stash {
p.id.clone()
} else {
match tmux.ensure_stash_window(session_name) {
Ok(id) => {
eprintln!("[repair] created primary stash window {}", id);
id
}
Err(e) => {
eprintln!("[repair] failed to create stash window: {}", e);
return Ok(());
}
}
};
for sec_id in &secondary_stash_ids {
eprintln!("[repair] consolidating stash window {} into {}", sec_id, primary_id);
let panes = tmux.list_window_panes(sec_id).unwrap_or_default();
for pane in &panes {
let _ = tmux.raw_cmd(&[
"resize-window", "-t", &primary_id, "-y", "1000",
]);
let target = tmux.largest_pane_in_window(&primary_id)
.unwrap_or_else(|| {
tmux.list_window_panes(&primary_id)
.unwrap_or_default()
.into_iter()
.next()
.unwrap_or_default()
});
if target.is_empty() {
eprintln!("[repair] no target pane in primary stash, skipping {}", pane);
continue;
}
match PaneMoveOp::new(tmux, pane, &target).join("-dv") {
Ok(()) => {
eprintln!("[repair] joined pane {} → stash {}", pane, primary_id);
}
Err(e) => {
eprintln!("[repair] join-pane {} → {} failed: {}, leaving in place", pane, target, e);
}
}
}
let remaining = tmux.list_window_panes(sec_id).unwrap_or_default();
if remaining.is_empty() {
let _ = tmux.raw_cmd(&["kill-window", "-t", sec_id]);
eprintln!("[repair] killed empty stash window {}", sec_id);
}
}
}
let target_exists = windows.iter().any(|w| w.name == target_window_name);
if !target_exists {
eprintln!(
"[repair] target window '{}' not found, attempting to rescue a pane from stash",
target_window_name
);
if let Ok(registry) = sessions::load() {
let mut rescued = false;
for entry in registry.values() {
if tmux.pane_alive(&entry.pane) {
eprintln!("[repair] rescuing pane {} from stash", entry.pane);
match tmux.break_pane(&entry.pane) {
Ok(()) => {
if let Ok(new_win) = tmux.pane_window(&entry.pane) {
let _ = tmux.raw_cmd(&[
"rename-window", "-t", &new_win, target_window_name,
]);
eprintln!(
"[repair] recreated window {} as '{}'",
new_win, target_window_name
);
}
rescued = true;
break;
}
Err(e) => {
eprintln!("[repair] break-pane {} failed: {}", entry.pane, e);
}
}
}
}
if !rescued {
eprintln!("[repair] no alive registered panes found, sync will auto-start later");
}
}
}
}
let output = tmux.raw_cmd(&[
"list-windows", "-t", &format!("{}:", session_name),
"-F", "#{window_index} #{window_name}",
]);
if let Ok(ref listing) = output {
let window_0_exists = listing.lines().any(|line| {
line.starts_with("0 ")
});
for line in listing.lines() {
let mut parts = line.splitn(2, ' ');
if let (Some(idx), Some(name)) = (parts.next(), parts.next())
&& name == target_window_name && idx != "0"
{
if window_0_exists {
eprintln!("[repair] swapping {}:{} with window 0", idx, name);
let _ = tmux.raw_cmd(&[
"swap-window",
"-s", &format!("{}:{}", session_name, idx),
"-t", &format!("{}:0", session_name),
]);
} else {
eprintln!("[repair] moving {}:{} to index 0", idx, name);
let _ = tmux.raw_cmd(&[
"move-window",
"-s", &format!("{}:{}", session_name, idx),
"-t", &format!("{}:0", session_name),
]);
}
break;
}
}
}
Ok(())
}
fn sync_log(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true).append(true)
.open("/tmp/agent-doc-sync.log")
{
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(f, "[{}] {}", ts, msg);
}
}
fn check_build_stamp() {
let build_ts = env!("AGENT_DOC_BUILD_TIMESTAMP");
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(_) => return,
};
let stamp_path = cwd.join(".agent-doc/build.stamp");
let stored = std::fs::read_to_string(&stamp_path).unwrap_or_default();
if stored.trim() == build_ts {
return; }
eprintln!("[sync] new build detected ({}→{}), clearing stale caches", stored.trim(), build_ts);
let starting_dir = cwd.join(".agent-doc/starting");
if starting_dir.exists()
&& let Ok(entries) = std::fs::read_dir(&starting_dir)
{
for entry in entries.flatten() {
if entry.path().extension().map(|e| e == "lock").unwrap_or(false) {
let _ = std::fs::remove_file(entry.path());
}
}
}
if let Some(parent) = stamp_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&stamp_path, build_ts);
}
fn run_with_options(
col_args: &[String],
window: Option<&str>,
focus: Option<&str>,
auto_start: bool,
tmux: &Tmux,
) -> Result<()> {
tracing::debug!(cols = ?col_args, window, focus, auto_start, "sync::run_with_options start");
let lock_path = std::path::Path::new(".agent-doc/sync.lock");
if let Some(parent) = lock_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(lock_path);
let _lock_guard = lock_file.as_ref().ok().map(|f| {
use fs2::FileExt;
match f.try_lock_exclusive() {
Ok(()) => Some(()),
Err(_) => {
sync_log("sync lock contention — waiting for previous sync");
let _ = f.lock_exclusive();
sync_log("sync lock acquired after wait");
Some(())
}
}
});
check_build_stamp();
let col_args: Vec<String> = col_args.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
let layout_state_path = std::path::Path::new(".agent-doc/last_layout.json");
let saved_layout: Vec<String> = std::fs::read_to_string(layout_state_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let col_args: Vec<String> = col_args.iter().enumerate().map(|(i, col)| {
let has_agent_doc = col.split(',').any(|f| {
let f = f.trim();
if f.is_empty() { return false; }
if let Ok(content) = std::fs::read_to_string(f)
&& let Ok((fm, _)) = frontmatter::parse(&content) {
return fm.session.is_some();
}
false
});
if has_agent_doc {
col.clone()
} else if let Some(remembered) = saved_layout.get(i) {
if !remembered.is_empty() {
sync_log(&format!("column {} has no agent doc, substituting remembered: {}", i, remembered));
remembered.clone()
} else {
col.clone()
}
} else {
col.clone()
}
}).collect();
let col_args = col_args.as_slice();
sync_log(&format!("=== sync start: col_args={:?} window={:?} focus={:?} auto_start={}", col_args, window, focus, auto_start));
let mut effective_window = window.map(|s| s.to_string());
if let Some(ref w) = effective_window {
let session_name = tmux
.cmd()
.args(["display-message", "-t", w, "-p", "#{session_name}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.or_else(|| {
tmux.cmd()
.args(["display-message", "-p", "#{session_name}"])
.output().ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
})
.unwrap_or_default();
if !session_name.is_empty() {
let _ = repair_layout(tmux, &session_name, "agent-doc");
sync_log("repair_layout completed");
let resolved = tmux.raw_cmd(&[
"list-windows", "-t", &format!("{}:", session_name),
"-F", "#{window_id} #{window_name}",
]);
if let Ok(ref output) = resolved {
for line in output.lines() {
let mut parts = line.splitn(2, ' ');
if let (Some(wid), Some(wname)) = (parts.next(), parts.next())
&& wname == "agent-doc"
{
if wid != w.as_str() {
eprintln!("[sync] window ID changed after repair: {} → {}", w, wid);
effective_window = Some(wid.to_string());
}
break;
}
}
}
}
}
let window = effective_window.as_deref();
if let Some(w) = window {
let pane_count = tmux.list_window_panes(w).map(|p| p.len()).unwrap_or(0);
let pane_list: Vec<String> = tmux.list_window_panes(w).unwrap_or_default();
sync_log(&format!("checkpoint:post-repair window={} panes={} list={:?}", w, pane_count, pane_list));
}
let _ = resync::prune();
if let Some(w) = window {
let pane_list: Vec<String> = tmux.list_window_panes(w).unwrap_or_default();
sync_log(&format!("checkpoint:post-prune window={} panes={} list={:?}", w, pane_list.len(), pane_list));
}
let registry_path = sessions::registry_path();
let session_files: RefCell<Vec<(String, PathBuf)>> = RefCell::new(Vec::new());
let resolve_file = |path: &Path| -> Option<FileResolution> {
if path.extension() == Some(std::ffi::OsStr::new("md")) {
let raw = std::fs::read_to_string(path).unwrap_or_default();
if raw.trim().is_empty() {
eprintln!("[sync] auto-scaffolding empty file: {}", path.display());
let session_id = uuid::Uuid::new_v4();
let scaffold = format!(
"---\nagent_doc_session: {}\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Status\n\n<!-- agent:status patch=replace -->\n<!-- /agent:status -->\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n\n## Pending / Not Built\n\n<!-- agent:pending patch=replace -->\n<!-- /agent:pending -->\n",
session_id
);
if let Err(e) = std::fs::write(path, &scaffold) {
eprintln!("[sync] warning: failed to scaffold {}: {}", path.display(), e);
return Some(FileResolution::Unmanaged);
}
if let Err(e) = crate::snapshot::save(path, &scaffold) {
eprintln!("[sync] warning: failed to save scaffold snapshot for {}: {}", path.display(), e);
}
if let Err(e) = crate::git::commit(path) {
eprintln!("[sync] warning: failed to commit scaffold for {}: {}", path.display(), e);
}
}
}
if let Err(e) = crate::snapshot::ensure_initialized(path) {
eprintln!("[sync] warning: ensure_initialized failed for {}: {}", path.display(), e);
}
let content = std::fs::read_to_string(path).ok()?;
let (fm, _) = frontmatter::parse(&content).ok()?;
match fm.session {
Some(ref key) => {
let has_registry = sessions::lookup(key).ok().flatten().is_some();
let registry_str = if has_registry { "yes" } else { "no (will auto-start)" };
tracing::debug!(
file = %path.display(),
session = &key[..8.min(key.len())],
registry = registry_str,
"sync resolve_file → Registered"
);
eprintln!(
"[sync] resolve_file: {} → Registered (session={}, registry={})",
path.display(), &key[..8.min(key.len())], registry_str
);
session_files
.borrow_mut()
.push((key.clone(), path.to_path_buf()));
Some(FileResolution::Registered {
key: key.clone(),
tmux_session: None,
})
}
None => {
Some(FileResolution::Unmanaged)
}
}
};
if let Some(w) = window {
let window_exists = tmux.list_window_panes(w).map(|p| !p.is_empty()).unwrap_or(false);
if !window_exists {
eprintln!("[sync] target window {} does not exist, attempting to recreate from stash", w);
let all_files: Vec<PathBuf> = col_args
.iter()
.flat_map(|arg| arg.split(','))
.map(|s| PathBuf::from(s.trim()))
.collect();
for file_path in &all_files {
if let Ok(content) = std::fs::read_to_string(file_path)
&& let Ok((fm, _)) = frontmatter::parse(&content)
&& let Some(ref sid) = fm.session
&& let Ok(Some(pane)) = sessions::lookup(sid)
&& tmux.pane_alive(&pane)
{
eprintln!("[sync] rescuing pane {} for {} from stash", pane, file_path.display());
if tmux.break_pane(&pane).is_ok() {
if let Ok(new_win) = tmux.pane_window(&pane) {
let _ = tmux.raw_cmd(&["rename-window", "-t", &new_win, "agent-doc"]);
eprintln!("[sync] recreated window {} as agent-doc", new_win);
}
break;
}
}
}
}
}
if auto_start {
let all_files: Vec<PathBuf> = col_args
.iter()
.flat_map(|arg| arg.split(','))
.map(|s| PathBuf::from(s.trim()))
.collect();
let context_session: Option<String> = window
.and_then(|w| {
let output = tmux
.cmd()
.args(["display-message", "-t", w, "-p", "#{session_name}"])
.output()
.ok()?;
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() { Some(name) } else { None }
} else {
None
}
});
for file_path in &all_files {
if !file_path.exists() {
continue;
}
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
};
let (fm, _) = match frontmatter::parse(&content) {
Ok(r) => r,
Err(_) => continue,
};
let session_id = match fm.session {
Some(ref id) => id.clone(),
None => continue,
};
let registered_pane = sessions::lookup(&session_id)
.ok()
.flatten();
eprintln!(
"[sync] auto-start check: {} session={} registered_pane={}",
file_path.display(),
&session_id[..8.min(session_id.len())],
registered_pane.as_deref().unwrap_or("none")
);
let has_alive_pane = registered_pane
.as_ref()
.map(|pane| {
if !tmux.pane_alive(pane) {
return false;
}
if let Ok(win_id) = tmux.pane_window(pane) {
let win_name = tmux
.cmd()
.args(["display-message", "-t", &win_id, "-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 win_name == "stash" || win_name.starts_with("stash-") {
let pane_session = tmux
.cmd()
.args(["display-message", "-t", pane, "-p", "#{session_name}"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let target_sess = context_session.as_deref().unwrap_or("");
if !target_sess.is_empty() && pane_session != target_sess {
eprintln!(
"[sync] pane {} for {} is in session '{}' stash, moving to target session '{}' stash first",
pane, file_path.display(), pane_session, target_sess
);
if let Err(e) = tmux.stash_pane(pane, target_sess) {
eprintln!("[sync] stash_pane to target session failed: {}", e);
return false;
}
}
eprintln!(
"[sync] pane {} for {} is in stash window '{}', rescuing",
pane, file_path.display(), win_name
);
if let Some(target_win) = window {
let agent_doc_window = format!("{}:agent-doc", target_win);
let target_panes = tmux.list_window_panes(&agent_doc_window).unwrap_or_default();
if let Some(target) = target_panes.first() {
let swap_session = target_sess.to_string();
match sessions::swap_pane_guarded(tmux, pane, target, &swap_session) {
Ok(()) => {
eprintln!("[sync] rescued pane {} via swap-pane", pane);
return true;
}
Err(e) => {
eprintln!("[sync] swap-pane rescue failed ({}), trying join-pane", e);
if PaneMoveOp::new(tmux, pane, target).join("-dh").is_ok() {
eprintln!("[sync] rescued pane {} via join-pane", pane);
return true;
}
}
}
}
}
eprintln!("[sync] rescue failed for pane {}, treating as dead", pane);
return false;
}
}
true
})
.unwrap_or(false);
if has_alive_pane {
if let Some(ref pane) = registered_pane {
if let Ok(Some(entry)) = sessions::lookup_entry(&session_id) {
let registered_file = Path::new(&entry.file);
let current_file = file_path.to_string_lossy();
if entry.file != *current_file && !registered_file.exists() {
eprintln!(
"[sync] registered file {} no longer exists (renamed to {}), killing stale pane {}",
entry.file, file_path.display(), pane
);
let _ = tmux.kill_pane(pane);
if let Err(e) = sessions::register(&session_id, pane, ¤t_file) {
eprintln!("[sync] warning: re-register failed: {}", e);
}
} else {
continue;
}
} else {
continue;
}
} else {
continue;
}
}
let file_str = file_path.to_string_lossy().to_string();
if let Some(existing) = find_alive_pane_for_file(tmux, &file_str) {
eprintln!(
"[sync] found alive pane {} for {} (re-registering)",
existing, file_path.display()
);
if let Err(e) = sessions::register(&session_id, &existing, &file_str) {
eprintln!(
"[sync] warning: re-register failed for {}: {}",
file_path.display(), e
);
}
continue;
}
sync_log(&format!("auto-starting session for {} (no alive pane)", file_path.display()));
eprintln!(
"[sync] auto-starting session for {} (no alive pane)",
file_path.display()
);
if let Err(e) = route::provision_pane(tmux, file_path, &session_id, &file_str, context_session.as_deref(), col_args) {
eprintln!(
"[sync] warning: auto-start failed for {}: {}",
file_path.display(),
e
);
}
}
}
if let Some(w) = window {
let pane_list: Vec<String> = tmux.list_window_panes(w).unwrap_or_default();
sync_log(&format!("checkpoint:pre-tmux_router window={} panes={} list={:?}", w, pane_list.len(), pane_list));
}
let result =
tmux_router::sync(col_args, window, focus, tmux, ®istry_path, &resolve_file)?;
if let Some(w) = window {
let pane_count = tmux.list_window_panes(w).map(|p| p.len()).unwrap_or(0);
sync_log(&format!("post-tmux_router::sync: window={} panes={} file_panes={}", w, pane_count, result.file_panes.len()));
tracing::debug!(window = w, pane_count, file_panes = result.file_panes.len(), "post-sync pane count");
if let Ok(session) = tmux.cmd()
.args(["display-message", "-t", w, "-p", "#{session_name}"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
&& !session.is_empty()
{
let session_alive = tmux.cmd()
.args(["has-session", "-t", &session])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !session_alive {
tracing::error!(session = %session, "SESSION DESTROYED after sync — tmux session no longer exists");
eprintln!("[sync] CRITICAL: session '{}' was destroyed during sync!", session);
}
}
}
{
let layout_state: Vec<String> = col_args.iter().map(|col| {
for f in col.split(',').map(|f| f.trim()) {
if f.is_empty() { continue; }
if let Ok(content) = std::fs::read_to_string(f)
&& let Ok((fm, _)) = frontmatter::parse(&content)
&& fm.session.is_some() {
return f.to_string();
}
}
String::new()
}).collect();
if layout_state.iter().any(|s| !s.is_empty())
&& let Ok(json) = serde_json::to_string(&layout_state) {
let _ = std::fs::write(layout_state_path, json);
}
}
register_synced_files(&session_files.borrow(), &result.file_panes);
if let Err(e) = resync::run(false, None) {
eprintln!("[sync] warning: post-sync resync failed: {}", e);
}
Ok(())
}
fn register_synced_files(
session_files: &[(String, PathBuf)],
file_panes: &[(PathBuf, String)],
) {
if session_files.is_empty() || file_panes.is_empty() {
return;
}
let pane_lookup: std::collections::HashMap<&Path, &str> = file_panes
.iter()
.map(|(p, id)| (p.as_path(), id.as_str()))
.collect();
let registry_path = sessions::registry_path();
let Ok(_lock) = sessions::RegistryLock::acquire(®istry_path) else {
return;
};
let Ok(mut registry) = sessions::load() else {
return;
};
let mut changed = false;
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
for (session_id, file_path) in session_files {
let file_str = file_path.to_string_lossy().to_string();
if let Some(entry) = registry.get_mut(session_id) {
if entry.file != file_str {
eprintln!(
"[sync] updating file path for session {} → {}",
&session_id[..8.min(session_id.len())],
file_path.display()
);
entry.file = file_str;
changed = true;
}
if let Some(&pane_id) = pane_lookup.get(file_path.as_path())
&& entry.pane != pane_id
{
eprintln!(
"[sync] updating pane for {} → {}",
file_path.display(),
pane_id
);
entry.pane = pane_id.to_string();
changed = true;
}
} else if let Some(&pane_id) = pane_lookup.get(file_path.as_path()) {
let pane_pid = sessions::pane_pid(pane_id).unwrap_or(std::process::id());
let window = sessions::pane_window(pane_id).unwrap_or_default();
eprintln!(
"[sync] registering {} → pane {} (session {})",
file_path.display(),
pane_id,
&session_id[..8.min(session_id.len())]
);
registry.insert(
session_id.clone(),
sessions::SessionEntry {
pane: pane_id.to_string(),
pid: pane_pid,
cwd: cwd.clone(),
started: String::new(),
file: file_str,
window,
},
);
changed = true;
}
}
if changed {
let _ = sessions::save(®istry);
}
}
fn find_alive_pane_for_file(tmux: &Tmux, file_path: &str) -> Option<String> {
let output = tmux.cmd()
.args(["list-panes", "-a", "-F", "#{pane_id} #{pane_pid}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() != 2 {
continue;
}
let pane_id = parts[0];
let pid_str = parts[1];
if pid_has_agent_doc_for_file(pid_str, file_path) {
eprintln!(
"[sync] found alive agent-doc pane {} (pid {}) for {}",
pane_id, pid_str, file_path
);
return Some(pane_id.to_string());
}
if let Ok(children) = std::process::Command::new("pgrep")
.args(["-P", pid_str])
.output()
{
for child_pid in String::from_utf8_lossy(&children.stdout).lines() {
let child_pid = child_pid.trim();
if !child_pid.is_empty() && pid_has_agent_doc_for_file(child_pid, file_path) {
eprintln!(
"[sync] found alive agent-doc child (pid {}) in pane {} for {}",
child_pid, pane_id, file_path
);
return Some(pane_id.to_string());
}
}
}
}
None
}
#[allow(dead_code)]
fn is_pane_busy(tmux: &Tmux, pane_id: &str) -> bool {
let output = tmux.cmd()
.args(["display-message", "-t", pane_id, "-p", "#{pane_pid}"])
.output();
let pid_str = match output {
Ok(ref o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => return false,
};
if pid_str.is_empty() {
return false;
}
if pid_is_agent_session(&pid_str) {
return true;
}
if let Ok(children) = std::process::Command::new("pgrep")
.args(["-P", &pid_str])
.output()
{
for child_pid in String::from_utf8_lossy(&children.stdout).lines() {
let child_pid = child_pid.trim();
if !child_pid.is_empty() && pid_is_agent_session(child_pid) {
return true;
}
}
}
false
}
#[allow(dead_code)]
fn pid_is_agent_session(pid: &str) -> bool {
let output = match std::process::Command::new("ps")
.args(["-p", pid, "-o", "command="])
.output()
{
Ok(o) if o.status.success() => o,
_ => return false,
};
let cmdline = String::from_utf8_lossy(&output.stdout);
cmdline.contains("agent-doc") || cmdline.contains("claude")
}
fn pid_has_agent_doc_for_file(pid: &str, file_path: &str) -> bool {
let output = match std::process::Command::new("ps")
.args(["-p", pid, "-o", "command="])
.output()
{
Ok(o) if o.status.success() => o,
_ => return false,
};
let cmdline = String::from_utf8_lossy(&output.stdout);
let has_agent = cmdline.contains("agent-doc") || cmdline.contains("claude");
let has_file = cmdline.contains(file_path);
has_agent && has_file
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessions::IsolatedTmux;
fn list_windows(tmux: &Tmux, session: &str) -> Vec<(String, String)> {
let output = tmux
.raw_cmd(&[
"list-windows",
"-t",
&format!("{}:", session),
"-F",
"#{window_index} #{window_name}",
])
.unwrap();
output
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, ' ');
let idx = parts.next()?.to_string();
let name = parts.next()?.to_string();
Some((idx, name))
})
.collect()
}
#[test]
fn repair_layout_skips_correct_state() {
let iso = IsolatedTmux::new("sync-repair-skip-correct");
let tmp = tempfile::TempDir::new().unwrap();
let _pane = iso.new_session("test", tmp.path()).unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", "test:0", "agent-doc"]);
let _ = iso.ensure_stash_window("test");
let windows_before = list_windows(&iso, "test");
repair_layout(&iso, "test", "agent-doc").unwrap();
let windows_after = list_windows(&iso, "test");
assert_eq!(windows_before, windows_after, "layout was already correct — nothing should change");
}
#[test]
fn repair_layout_moves_window_to_index_0() {
let iso = IsolatedTmux::new("sync-repair-move-idx0");
let tmp = tempfile::TempDir::new().unwrap();
let _pane0 = iso.new_session("test", tmp.path()).unwrap();
let _ = iso.raw_cmd(&[
"new-window", "-t", "test:", "-n", "stash", "-d",
]);
let _ = iso.raw_cmd(&[
"new-window", "-t", "test:", "-n", "agent-doc", "-d",
]);
let _ = iso.raw_cmd(&["kill-window", "-t", "test:0"]);
let windows_before = list_windows(&iso, "test");
let ad_before = windows_before.iter().find(|(_, n)| n == "agent-doc");
assert!(ad_before.is_some(), "agent-doc window should exist");
assert_ne!(ad_before.unwrap().0, "0", "agent-doc should NOT be at index 0 before repair");
repair_layout(&iso, "test", "agent-doc").unwrap();
let windows_after = list_windows(&iso, "test");
let ad_after = windows_after.iter().find(|(_, n)| n == "agent-doc");
assert!(ad_after.is_some(), "agent-doc window should still exist");
assert_eq!(ad_after.unwrap().0, "0", "agent-doc should be at index 0 after repair");
}
#[test]
fn repair_layout_rescues_pane_from_stash() {
let iso = IsolatedTmux::new("sync-repair-rescue-stash");
let tmp = tempfile::TempDir::new().unwrap();
let pane1 = iso.new_session("test", tmp.path()).unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", "test:0", "other"]);
let pane2 = iso.split_window(&pane1, tmp.path(), "-dh").unwrap();
iso.stash_pane(&pane2, "test").unwrap();
let windows_before = list_windows(&iso, "test");
assert!(
!windows_before.iter().any(|(_, n)| n == "agent-doc"),
"agent-doc window should NOT exist before repair"
);
let result = repair_layout(&iso, "test", "agent-doc");
assert!(result.is_ok(), "repair_layout should not error");
assert!(iso.pane_alive(&pane2), "stashed pane should still be alive");
}
#[test]
fn repair_layout_consolidates_multiple_stash_windows() {
let iso = IsolatedTmux::new("sync-repair-consolidate");
let tmp = tempfile::TempDir::new().unwrap();
let pane0 = iso.new_session("test", tmp.path()).unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", "test:0", "agent-doc"]);
let p1 = iso.split_window(&pane0, tmp.path(), "-dh").unwrap();
let _p2 = iso.split_window(&pane0, tmp.path(), "-dh").unwrap();
let _p3 = iso.split_window(&pane0, tmp.path(), "-dh").unwrap();
iso.stash_pane(&p1, "test").unwrap();
let _ = iso.raw_cmd(&[
"new-window", "-t", "test:", "-n", "stash", "-d", "-P", "-F", "#{window_id}",
]);
let stash_windows: Vec<String> = {
let output = iso.raw_cmd(&[
"list-windows", "-t", "test:", "-F", "#{window_id} #{window_name}",
]).unwrap();
output.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, ' ');
let id = parts.next()?;
let name = parts.next()?;
if name == "stash" || name.starts_with("stash-") {
Some(id.to_string())
} else {
None
}
})
.collect()
};
assert!(stash_windows.len() >= 2, "should have multiple stash windows, got {}", stash_windows.len());
let windows_before = list_windows(&iso, "test");
let stash_count_before = windows_before.iter()
.filter(|(_, n)| n == "stash" || n.starts_with("stash-"))
.count();
assert!(stash_count_before >= 2, "should have >=2 stash windows before repair, got {}", stash_count_before);
repair_layout(&iso, "test", "agent-doc").unwrap();
let windows_after = list_windows(&iso, "test");
let stash_count_after = windows_after.iter()
.filter(|(_, n)| n == "stash" || n.starts_with("stash-"))
.count();
assert!(
stash_count_after <= 1,
"should have at most 1 stash window after consolidation, got {}",
stash_count_after
);
let ad = windows_after.iter().find(|(_, n)| n == "agent-doc");
assert!(ad.is_some(), "agent-doc window should still exist");
assert_eq!(ad.unwrap().0, "0", "agent-doc should be at index 0");
}
#[test]
fn repair_layout_swaps_when_index_0_occupied() {
let iso = IsolatedTmux::new("sync-repair-swap-idx0");
let tmp = tempfile::TempDir::new().unwrap();
let _pane0 = iso.new_session("test", tmp.path()).unwrap();
let _ = iso.raw_cmd(&["rename-window", "-t", "test:0", "corky"]);
let _ = iso.raw_cmd(&[
"new-window", "-t", "test:", "-n", "stash", "-d",
]);
let _ = iso.raw_cmd(&[
"new-window", "-t", "test:", "-n", "agent-doc", "-d",
]);
let windows_before = list_windows(&iso, "test");
assert_eq!(windows_before.iter().find(|(i, _)| i == "0").unwrap().1, "corky");
assert_eq!(windows_before.iter().find(|(i, _)| i == "2").unwrap().1, "agent-doc");
repair_layout(&iso, "test", "agent-doc").unwrap();
let windows_after = list_windows(&iso, "test");
let ad = windows_after.iter().find(|(_, n)| n == "agent-doc");
assert!(ad.is_some(), "agent-doc window should still exist");
assert_eq!(ad.unwrap().0, "0", "agent-doc should be at index 0 after swap");
let corky = windows_after.iter().find(|(_, n)| n == "corky");
assert!(corky.is_some(), "corky window should still exist (not destroyed)");
assert_ne!(corky.unwrap().0, "0", "corky should have moved away from index 0");
assert_eq!(windows_after.len(), 3, "no windows should be destroyed, got {:?}", windows_after);
}
#[test]
fn sync_does_not_write_tmux_session_to_frontmatter() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("test.md");
std::fs::write(&doc, "---\nagent_doc_session: test-123\n---\n\n## User\n\nHello\n").unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert!(fm.tmux_session.is_none(), "tmux_session should not be set initially");
let doc2 = tmp.path().join("test2.md");
std::fs::write(&doc2, "---\nagent_doc_session: test-456\ntmux_session: old-session\n---\n\n## User\n\nHello\n").unwrap();
let content2 = std::fs::read_to_string(&doc2).unwrap();
let (fm2, _) = crate::frontmatter::parse(&content2).unwrap();
assert_eq!(fm2.tmux_session, Some("old-session".to_string()),
"frontmatter parser should still read tmux_session for backward compat");
}
#[test]
fn resolve_file_ignores_frontmatter_tmux_session() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("test.md");
std::fs::write(&doc, "---\nagent_doc_session: sess-1\ntmux_session: stale-session\n---\n\nbody\n").unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
let resolution = match fm.session {
Some(key) => FileResolution::Registered {
key,
tmux_session: None, },
None => FileResolution::Unmanaged,
};
match resolution {
FileResolution::Registered { tmux_session, .. } => {
assert!(tmux_session.is_none(),
"FileResolution must never carry tmux_session from frontmatter");
}
_ => panic!("expected Registered"),
}
}
#[test]
fn sync_skips_file_without_session_in_frontmatter() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("no-session.md");
std::fs::write(&doc, "# Just a regular file\n\nNo frontmatter at all.\n").unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert!(fm.session.is_none(), "file should have no session UUID");
let resolution = match fm.session {
Some(_) => unreachable!("session should be None"),
None => FileResolution::Unmanaged,
};
assert!(matches!(resolution, FileResolution::Unmanaged));
}
#[test]
fn sync_skips_file_with_session_uuid_but_no_registry() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
std::fs::write(
tmp.path().join(".agent-doc/sessions.json"),
"{}",
).unwrap();
let doc = tmp.path().join("stale-claim.md");
std::fs::write(
&doc,
"---\nagent_doc_session: orphan-uuid-123\n---\n\n## User\n\nHello\n",
).unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert_eq!(fm.session, Some("orphan-uuid-123".to_string()));
let reg_content = std::fs::read_to_string(
tmp.path().join(".agent-doc/sessions.json"),
).unwrap();
let registry: sessions::SessionRegistry = serde_json::from_str(®_content).unwrap();
let has_registry_entry = registry.contains_key("orphan-uuid-123");
assert!(!has_registry_entry, "should NOT have a registry entry");
let resolution = if has_registry_entry {
FileResolution::Registered {
key: "orphan-uuid-123".to_string(),
tmux_session: None,
}
} else {
FileResolution::Unmanaged
};
assert!(
matches!(resolution, FileResolution::Unmanaged),
"file with session UUID but no registry entry should be Unmanaged"
);
}
#[test]
fn sync_routes_file_with_session_uuid_and_registry_entry() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
let registry_content = serde_json::json!({
"claimed-uuid-456": {
"pane": "%99",
"pid": 12345,
"cwd": "/tmp",
"started": "2026-01-01T00:00:00Z",
"file": "claimed.md",
"window": "@0"
}
});
std::fs::write(
tmp.path().join(".agent-doc/sessions.json"),
serde_json::to_string_pretty(®istry_content).unwrap(),
).unwrap();
let doc = tmp.path().join("claimed.md");
std::fs::write(
&doc,
"---\nagent_doc_session: claimed-uuid-456\n---\n\n## User\n\nHello\n",
).unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert_eq!(fm.session, Some("claimed-uuid-456".to_string()));
let reg_content = std::fs::read_to_string(
tmp.path().join(".agent-doc/sessions.json"),
).unwrap();
let registry: sessions::SessionRegistry = serde_json::from_str(®_content).unwrap();
let has_registry_entry = registry.contains_key("claimed-uuid-456");
assert!(has_registry_entry, "should have a registry entry");
let resolution = if has_registry_entry {
FileResolution::Registered {
key: "claimed-uuid-456".to_string(),
tmux_session: None,
}
} else {
FileResolution::Unmanaged
};
assert!(
matches!(resolution, FileResolution::Registered { .. }),
"file with session UUID AND registry entry should be Registered"
);
}
#[test]
fn empty_col_args_filtered() {
let col_args: Vec<String> = vec![
"file1.md".into(),
"".into(),
"file2.md".into(),
"".into(),
" ".into(),
];
let filtered: Vec<String> = col_args
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
assert_eq!(filtered, vec!["file1.md", "file2.md"]);
}
#[test]
fn sync_auto_scaffolds_empty_md_file() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path();
std::fs::create_dir_all(project.join(".agent-doc/snapshots")).unwrap();
let doc = project.join("test.md");
std::fs::write(&doc, "").unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
assert!(content.trim().is_empty(), "file should be empty");
let session_id = uuid::Uuid::new_v4();
let scaffold = format!(
"---\nagent_doc_session: {}\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Status\n\n<!-- agent:status patch=replace -->\n<!-- /agent:status -->\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n\n## Pending / Not Built\n\n<!-- agent:pending patch=replace -->\n<!-- /agent:pending -->\n",
session_id
);
std::fs::write(&doc, &scaffold).unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert!(fm.session.is_some(), "should have session UUID after scaffold");
assert!(fm.format.is_some(), "should have format after scaffold");
assert!(content.contains("<!-- agent:exchange"), "should have exchange component");
}
#[test]
fn sync_does_not_scaffold_non_empty_md_file() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("notes.md");
std::fs::write(&doc, "# My Notes\n\nSome content here.\n").unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
assert!(!content.trim().is_empty(), "file is not empty");
}
#[test]
fn sync_scaffold_includes_all_components() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path();
std::fs::create_dir_all(project.join(".agent-doc/snapshots")).unwrap();
let doc = project.join("new-session.md");
std::fs::write(&doc, "").unwrap();
let raw = std::fs::read_to_string(&doc).unwrap();
assert!(raw.trim().is_empty());
let session_id = uuid::Uuid::new_v4();
let scaffold = format!(
"---\nagent_doc_session: {}\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Status\n\n<!-- agent:status patch=replace -->\n<!-- /agent:status -->\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n<!-- /agent:exchange -->\n\n## Pending / Not Built\n\n<!-- agent:pending patch=replace -->\n<!-- /agent:pending -->\n",
session_id
);
std::fs::write(&doc, &scaffold).unwrap();
let content = std::fs::read_to_string(&doc).unwrap();
let (fm, _) = crate::frontmatter::parse(&content).unwrap();
assert!(fm.session.is_some(), "must have session UUID");
assert!(fm.format.is_some(), "must have format set");
assert!(content.contains("<!-- agent:status patch=replace -->"), "must have status component");
assert!(content.contains("<!-- agent:exchange patch=append -->"), "must have exchange component");
assert!(content.contains("<!-- agent:pending patch=replace -->"), "must have pending component");
assert!(content.contains("<!-- /agent:status -->"), "status must be closed");
assert!(content.contains("<!-- /agent:exchange -->"), "exchange must be closed");
assert!(content.contains("<!-- /agent:pending -->"), "pending must be closed");
}
#[test]
fn sync_does_not_scaffold_non_md_files() {
let tmp = tempfile::TempDir::new().unwrap();
let txt = tmp.path().join("empty.txt");
std::fs::write(&txt, "").unwrap();
assert_ne!(txt.extension(), Some(std::ffi::OsStr::new("md")));
}
#[test]
fn sync_scaffolds_whitespace_only_file() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path();
std::fs::create_dir_all(project.join(".agent-doc/snapshots")).unwrap();
let doc = project.join("whitespace.md");
std::fs::write(&doc, " \n\n \n").unwrap();
let raw = std::fs::read_to_string(&doc).unwrap();
assert!(raw.trim().is_empty(), "whitespace-only should be treated as empty");
}
#[test]
fn sync_does_not_scaffold_file_with_existing_frontmatter() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("existing.md");
std::fs::write(&doc, "---\nagent_doc_session: test-123\n---\n").unwrap();
let raw = std::fs::read_to_string(&doc).unwrap();
assert!(!raw.trim().is_empty(), "file with frontmatter is not empty");
}
}