use anyhow::{Context, Result};
use std::io::Write;
use std::path::Path;
use crate::{frontmatter, resync, sessions};
pub fn run(file: &Path, position: Option<&str>, pane: Option<&str>, window: Option<&str>, _force: bool) -> Result<()> {
let _ = resync::prune();
validate_file_claim(file);
let file = &file.canonicalize().map_err(|_| {
anyhow::anyhow!("file not found: {}", file.display())
})?;
let effective_window: Option<String> = if let Some(win) = window {
let alive = is_window_alive(win);
if alive {
Some(win.to_string())
} else {
eprintln!("warning: window {} is dead, searching for alive window", win);
find_alive_project_window()
}
} else {
None
};
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!("Generated session UUID: {}", session_id);
}
let pane_id = if let Some(p) = pane {
p.to_string() } else if let Some(pos) = position {
if let Some(ref win) = effective_window {
sessions::pane_by_position_in_window(pos, win)?
} else {
sessions::pane_by_position(pos)?
}
} else {
sessions::current_pane()?
};
let tmux = sessions::Tmux::default_server();
{
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (fm, _) = frontmatter::parse(&content)?;
if fm.format.is_none() && fm.write_mode.is_none() && fm.mode.is_none() {
let updated = frontmatter::set_format_and_write(
&content,
frontmatter::AgentDocFormat::Template,
frontmatter::AgentDocWrite::Crdt,
)?;
if updated != content {
std::fs::write(file, &updated)
.with_context(|| format!("failed to write agent_doc_format/write to {}", file.display()))?;
eprintln!("set agent_doc_format=template, agent_doc_write=crdt in {}", file.display());
}
}
}
{
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (fm, _) = frontmatter::parse(&content)?;
let resolved = fm.resolve_mode();
let has_components = crate::component::parse(&content)
.map(|comps| comps.iter().any(|c| c.name == "status" || c.name == "exchange"))
.unwrap_or(false);
if resolved.format == frontmatter::AgentDocFormat::Template && !has_components {
let scaffolded = format!(
"{}\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",
content.trim_end()
);
std::fs::write(file, &scaffolded)
.with_context(|| format!("failed to write component scaffolding to {}", file.display()))?;
eprintln!("scaffolded default components in {}", file.display());
}
if resolved.format == frontmatter::AgentDocFormat::Template {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let project_root = crate::snapshot::find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
let components_toml = project_root.join(".agent-doc/components.toml");
if !components_toml.exists() {
if let Some(parent) = components_toml.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("warning: failed to create .agent-doc dir: {}", e);
}
let default_config = "[exchange]\nmode = \"append\"\n\n[findings]\nmode = \"append\"\n\n[status]\nmode = \"replace\"\n";
match std::fs::write(&components_toml, default_config) {
Ok(()) => eprintln!("created {}", components_toml.display()),
Err(e) => eprintln!("warning: failed to create components.toml: {}", e),
}
}
}
}
let file_str = file.to_string_lossy();
let pane_pid = sessions::pane_pid(&pane_id).unwrap_or(std::process::id());
sessions::register_with_pid(&session_id, &pane_id, &file_str, pane_pid)?;
if tmux.pane_alive(&pane_id) {
if let Err(e) = tmux.select_pane(&pane_id) {
eprintln!("warning: failed to focus pane {}: {}", pane_id, e);
} else {
eprintln!("focused pane {}", pane_id);
}
} else {
eprintln!("warning: pane {} is not alive, skipping focus", pane_id);
}
let msg = format!("Claimed {} (pane {})", file_str, pane_id);
if let Err(e) = tmux
.cmd()
.args(["display-message", "-t", &pane_id, "-d", "3000", &msg])
.status()
{
eprintln!("warning: display-message failed: {}", e);
}
let log_line = format!("Claimed {} for pane {}\n", file_str, pane_id);
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
let project_root = crate::snapshot::find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
let log_path = project_root.join(".agent-doc/claims.log");
if let Some(parent) = log_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("warning: failed to create claims log dir: {}", e);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(mut f) => {
if let Err(e) = write!(f, "{}", log_line) {
eprintln!("warning: failed to write claims log: {}", e);
}
}
Err(e) => eprintln!("warning: failed to open claims log: {}", e),
}
eprintln!(
"Claimed {} for pane {} (session {})",
file.display(),
pane_id,
&session_id[..8]
);
match crate::watch::ensure_running() {
Ok(true) => eprintln!("Watch daemon started."),
Ok(false) => {} Err(e) => eprintln!("warning: could not start watch daemon: {}", e),
}
Ok(())
}
fn validate_file_claim(file: &Path) {
let file_str = file.to_string_lossy();
let registry_path = sessions::registry_path();
let Ok(_lock) = sessions::RegistryLock::acquire(®istry_path) else {
return;
};
let Ok(registry) = sessions::load() else {
return;
};
let tmux = sessions::Tmux::default_server();
let stale_keys: Vec<(String, String)> = registry
.iter()
.filter(|(_, entry)| {
entry.file == file_str.as_ref() && !tmux.pane_alive(&entry.pane)
})
.map(|(k, e)| (k.clone(), e.pane.clone()))
.collect();
if stale_keys.is_empty() {
return;
}
let mut registry = registry;
for (key, pane) in &stale_keys {
eprintln!(
"stale claim: {} was bound to dead pane {}, replacing",
file_str, pane
);
registry.remove(key);
}
let _ = sessions::save(®istry);
}
fn is_window_alive(window: &str) -> bool {
std::process::Command::new("tmux")
.args(["list-panes", "-t", window, "-F", "#{pane_id}"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn find_alive_project_window() -> Option<String> {
let registry = sessions::load().ok()?;
let cwd = std::env::current_dir().ok()?.to_string_lossy().to_string();
find_alive_window_in_registry(®istry, &cwd, is_window_alive)
}
fn find_alive_window_in_registry(
registry: &sessions::SessionRegistry,
cwd: &str,
check_alive: impl Fn(&str) -> bool,
) -> Option<String> {
for entry in registry.values() {
if entry.cwd != cwd || entry.window.is_empty() {
continue;
}
if check_alive(&entry.window) {
eprintln!("found alive window {} from registry", entry.window);
return Some(entry.window.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessions::{SessionEntry, SessionRegistry};
fn make_entry(cwd: &str, window: &str) -> SessionEntry {
SessionEntry {
pane: "%0".to_string(),
pid: 1,
cwd: cwd.to_string(),
started: "2026-01-01".to_string(),
file: "test.md".to_string(),
window: window.to_string(),
}
}
#[test]
fn find_alive_window_returns_first_alive_match() {
let mut registry = SessionRegistry::new();
registry.insert("s1".into(), make_entry("/project", "@1"));
registry.insert("s2".into(), make_entry("/project", "@2"));
registry.insert("s3".into(), make_entry("/project", "@3"));
let result = find_alive_window_in_registry(®istry, "/project", |w| w == "@3");
assert_eq!(result, Some("@3".to_string()));
}
#[test]
fn find_alive_window_skips_wrong_cwd() {
let mut registry = SessionRegistry::new();
registry.insert("s1".into(), make_entry("/other-project", "@5"));
registry.insert("s2".into(), make_entry("/project", "@6"));
let result = find_alive_window_in_registry(®istry, "/project", |w| w == "@5" || w == "@6");
assert_eq!(result, Some("@6".to_string()));
}
#[test]
fn find_alive_window_skips_empty_window() {
let mut registry = SessionRegistry::new();
registry.insert("s1".into(), make_entry("/project", "")); registry.insert("s2".into(), make_entry("/project", "@7"));
let result = find_alive_window_in_registry(®istry, "/project", |_| true);
assert_eq!(result, Some("@7".to_string()));
}
#[test]
fn find_alive_window_returns_none_when_all_dead() {
let mut registry = SessionRegistry::new();
registry.insert("s1".into(), make_entry("/project", "@1"));
registry.insert("s2".into(), make_entry("/project", "@2"));
let result = find_alive_window_in_registry(®istry, "/project", |_| false);
assert_eq!(result, None);
}
#[test]
fn find_alive_window_returns_none_for_empty_registry() {
let registry = SessionRegistry::new();
let result = find_alive_window_in_registry(®istry, "/project", |_| true);
assert_eq!(result, None);
}
#[test]
fn find_alive_window_returns_none_when_no_cwd_match() {
let mut registry = SessionRegistry::new();
registry.insert("s1".into(), make_entry("/other", "@1"));
let result = find_alive_window_in_registry(®istry, "/project", |_| true);
assert_eq!(result, None);
}
}