use anyhow::{Context, Result};
use std::io::Write;
use std::path::Path;
use crate::{frontmatter, project_config, resync, route, 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 raw = std::fs::read_to_string(file).unwrap_or_default();
if raw.trim().is_empty() && file.extension() == Some(std::ffi::OsStr::new("md")) {
eprintln!("[claim] auto-scaffolding empty file: {}", file.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
);
std::fs::write(file, &scaffold)?;
crate::snapshot::save(file, &scaffold)?;
crate::git::commit(file).ok(); }
}
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)?;
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();
if tmux.pane_alive(&pane_id) {
let pane_tmux_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 let Some(configured) = crate::config::project_tmux_session()
&& !pane_tmux_session.is_empty()
&& pane_tmux_session != configured
{
if !force {
eprintln!(
"error: pane {} is in tmux session '{}' but project session is '{}'.\n\
Switch to session '{}' and re-run, or use --force to override.",
pane_id, pane_tmux_session, configured, configured
);
anyhow::bail!("session mismatch: cross-session claim rejected");
}
eprintln!(
"warning [--force]: registering cross-session pane {} (session '{}', configured '{}')",
pane_id, pane_tmux_session, configured
);
}
}
let file_str = file.to_string_lossy();
{
let registry = sessions::load().unwrap_or_default();
for (existing_id, entry) in ®istry {
if entry.pane == pane_id && *existing_id != session_id
&& tmux.pane_alive(&pane_id)
{
if force {
eprintln!("warning: overwriting claim on pane {} (was {} → {})", pane_id, &existing_id[..8], &session_id[..8]);
} else {
eprintln!(
"[claim] pane {} is already claimed by {} (file: {}); provisioning a new pane",
pane_id, &existing_id[..8], entry.file
);
route::provision_pane(&tmux, file, &session_id, &file_str, None, &[])?;
return Ok(());
}
}
}
}
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 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 mut proj_cfg = project_config::load_project();
proj_cfg.components.entry("exchange".to_string())
.or_insert_with(|| project_config::ComponentConfig {
patch: "append".to_string(),
..Default::default()
});
proj_cfg.components.entry("findings".to_string())
.or_insert_with(|| project_config::ComponentConfig {
patch: "append".to_string(),
..Default::default()
});
proj_cfg.components.entry("status".to_string())
.or_insert_with(|| project_config::ComponentConfig {
patch: "replace".to_string(),
..Default::default()
});
if let Err(e) = project_config::save_project(&proj_cfg) {
eprintln!("warning: failed to save config with components: {}", e);
} else {
eprintln!("merged default components into .agent-doc/config.toml");
}
}
}
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]
);
if let Err(e) = crate::snapshot::ensure_initialized(file) {
eprintln!("warning: failed to initialize document: {}", e);
}
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);
}
pub(crate) fn strip_exchange_content(content: &str) -> String {
if let Ok(components) = crate::component::parse(content)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
return exchange.replace_content(content, "\n");
}
content.to_string()
}
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);
}
#[test]
fn strip_exchange_content_removes_user_text() {
let content = "---\nagent_doc_session: abc\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
let result = strip_exchange_content(content);
assert!(result.contains("<!-- agent:exchange"));
assert!(!result.contains("User prompt here."));
}
#[test]
fn strip_exchange_content_preserves_no_exchange() {
let content = "---\nagent_doc_session: abc\n---\n\nJust text.\n";
let result = strip_exchange_content(content);
assert_eq!(result, content);
}
}