use anyhow::{bail, Context, Result};
use std::fmt::Write;
use std::path::Path;
use std::process::Command;
use super::helpers::*;
use super::types::*;
pub fn status(crosslink_dir: &Path, agent: &str) -> Result<()> {
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let slug = agent
.strip_prefix("feature/")
.or_else(|| agent.strip_prefix("feat-"))
.unwrap_or(agent);
let wt_slug = slug.rsplit("--").next().unwrap_or(slug);
let worktree_dir = root.join(".worktrees").join(wt_slug);
if !worktree_dir.exists() {
let worktrees_dir = root.join(".worktrees");
if worktrees_dir.is_dir() {
println!("Available worktrees:");
for entry in std::fs::read_dir(&worktrees_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let name = entry.file_name();
let status_file = entry.path().join(".kickoff-status");
let mut status = if status_file.exists() {
std::fs::read_to_string(&status_file)
.unwrap_or_default()
.trim()
.to_string()
} else {
"running".to_string()
};
if status == "running" && is_timed_out(&entry.path()) {
status = "timed-out".to_string();
}
println!(" {} — {}", name.to_string_lossy(), status);
}
}
} else {
println!("No worktrees found.");
}
return Ok(());
}
let status_file = worktree_dir.join(".kickoff-status");
let mut agent_status = if status_file.exists() {
std::fs::read_to_string(&status_file)
.unwrap_or_default()
.trim()
.to_string()
} else {
"running (no status file yet)".to_string()
};
if agent_status.contains("running") && is_timed_out(&worktree_dir) {
agent_status = "timed-out".to_string();
}
println!("Agent: {agent}");
println!("Worktree: {}", worktree_dir.display());
println!("Status: {agent_status}");
if let Some(meta) = read_timeout_metadata(&worktree_dir) {
let hours = meta.timeout_secs / 3600;
let mins = (meta.timeout_secs % 3600) / 60;
if hours > 0 {
println!("Timeout: {hours}h{mins}m");
} else {
println!("Timeout: {mins}m");
}
println!("Started: {}", meta.started_at);
}
let session_name = tmux_session_name(wt_slug);
if tmux_session_exists(&session_name) {
println!("tmux: active ({session_name})");
} else {
println!("tmux: no active session");
}
if let Ok(sync) = crate::sync::SyncManager::new(crosslink_dir) {
let cache = sync.cache_path();
for candidate in &[agent.to_string(), format!("driver--{wt_slug}")] {
let heartbeat_path = cache.join("agents").join(candidate).join("heartbeat.json");
if heartbeat_path.exists() {
if let Ok(content) = std::fs::read_to_string(&heartbeat_path) {
if let Ok(hb) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ts) = hb.get("timestamp").and_then(|v| v.as_str()) {
println!("Heartbeat: {ts}");
}
}
}
break;
}
}
}
Ok(())
}
pub(super) fn discover_agents(crosslink_dir: &Path) -> Result<Vec<AgentInfo>> {
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let worktrees_dir = root.join(".worktrees");
let mut agents: Vec<AgentInfo> = Vec::new();
if worktrees_dir.is_dir() {
for entry in std::fs::read_dir(&worktrees_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
let wt_path = entry.path();
let status_file = wt_path.join(".kickoff-status");
let agent_status = if status_file.exists() {
let raw = std::fs::read_to_string(&status_file)
.unwrap_or_default()
.trim()
.to_string();
normalize_status(&raw)
} else {
"running".to_string()
};
let issue = read_agent_issue(&wt_path, crosslink_dir);
let agent_id = read_agent_id(&wt_path, crosslink_dir)
.unwrap_or_else(|| format!("driver--{dir_name}"));
let session_name = std::fs::read_to_string(wt_path.join(".kickoff-session"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| tmux_session_name(&dir_name));
let tmux_active = tmux_session_exists(&session_name);
let final_status = if agent_status == "running" && is_timed_out(&wt_path) {
"timed-out".to_string()
} else if agent_status == "running" && !tmux_active {
"stopped".to_string()
} else {
agent_status
};
agents.push(AgentInfo {
id: agent_id,
issue,
status: final_status,
session: if tmux_active {
Some(session_name)
} else {
None
},
worktree: wt_path.to_string_lossy().to_string(),
docker: None,
});
}
}
if command_available("docker") {
if let Ok(output) = Command::new("docker")
.args([
"ps",
"-a",
"--filter",
"label=crosslink-agent=true",
"--format",
"{{.Names}}\t{{.Status}}\t{{.Label \"crosslink-task\"}}",
])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let container_name = parts[0];
let container_status_raw = parts[1];
let task_label = parts.get(2).unwrap_or(&"");
let matched = agents.iter_mut().find(|a| {
if task_label.is_empty() {
let slug = a.id.rsplit("--").next().unwrap_or(&a.id);
container_name.contains(slug)
} else {
a.worktree.contains(task_label)
}
});
if let Some(agent) = matched {
agent.docker = Some(container_name.to_string());
if container_status_raw.starts_with("Up") && agent.status == "stopped" {
agent.status = "running".to_string();
}
} else {
let docker_status = if container_status_raw.starts_with("Up") {
"running"
} else if container_status_raw.contains("Exited (0)") {
"done"
} else {
"failed"
};
agents.push(AgentInfo {
id: container_name.to_string(),
issue: if task_label.is_empty() {
None
} else {
Some(task_label.to_string())
},
status: docker_status.to_string(),
session: None,
worktree: String::new(),
docker: Some(container_name.to_string()),
});
}
}
}
}
}
}
Ok(agents)
}
pub fn list(crosslink_dir: &Path, status_filter: &str, json: bool, quiet: bool) -> Result<()> {
let agents = discover_agents(crosslink_dir)?;
let filtered: Vec<&AgentInfo> = if status_filter == "all" {
agents.iter().collect()
} else {
agents
.iter()
.filter(|a| a.status == status_filter)
.collect()
};
if quiet {
for agent in &filtered {
println!("{}", agent.id);
}
return Ok(());
}
if json {
println!("{}", serde_json::to_string_pretty(&filtered)?);
return Ok(());
}
if filtered.is_empty() {
println!("No kickoff agents found.");
return Ok(());
}
println!(
"{:<36} {:<8} {:<10} {:<24} WORKTREE",
"ID", "ISSUE", "STATUS", "SESSION"
);
for agent in &filtered {
let issue_display = agent.issue.as_deref().unwrap_or("-");
let session_display = agent.session.as_deref().unwrap_or("-");
let worktree_display = if agent.worktree.is_empty() {
"-"
} else {
std::path::Path::new(&agent.worktree)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&agent.worktree)
};
let status_display = if agent.docker.is_some() {
format!("{} \u{1f433}", agent.status)
} else {
agent.status.clone()
};
println!(
"{:<36} {:<8} {:<10} {:<24} {}",
truncate_str(&agent.id, 35),
truncate_str(issue_display, 7),
status_display,
truncate_str(session_display, 23),
worktree_display
);
}
Ok(())
}
pub fn logs(crosslink_dir: &Path, agent: &str, lines: usize) -> Result<()> {
if let Ok(sync) = crate::sync::SyncManager::new(crosslink_dir) {
let _ = sync.init_cache();
let _ = sync.fetch();
let cache = sync.cache_path();
let slug = agent.rsplit("--").next().unwrap_or(agent);
let agents_dir = cache.join("agents");
let mut found = false;
if agents_dir.is_dir() {
for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name == agent || name.ends_with(&format!("--{slug}")) {
found = true;
println!("Agent: {name}");
let hb_path = entry.path().join("heartbeat.json");
if hb_path.exists() {
let content = std::fs::read_to_string(&hb_path)?;
println!("Heartbeat: {}", content.trim());
}
let events_path = entry.path().join("events.log");
if events_path.exists() {
let metadata = std::fs::metadata(&events_path)?;
println!("Events log: {} bytes", metadata.len());
} else {
println!("Events log: (none)");
}
println!();
break;
}
}
}
if !found {
println!("No agent '{agent}' found on hub branch.");
println!("Available agents:");
if agents_dir.is_dir() {
for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?;
println!(" {}", entry.file_name().to_string_lossy());
}
}
}
} else {
bail!("Could not access hub branch. Run 'crosslink sync' first.");
}
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let slug = agent.rsplit("--").next().unwrap_or(agent);
let worktree_dir = root.join(".worktrees").join(slug);
if worktree_dir.exists() {
println!("Recent commits in worktree:");
let output = Command::new("git")
.current_dir(&worktree_dir)
.args([
"log",
"--oneline",
&format!("-{lines}"),
"--format=%h %s (%cr)",
])
.output();
if let Ok(o) = output {
if o.status.success() {
print!("{}", String::from_utf8_lossy(&o.stdout));
}
}
}
let _ = lines;
Ok(())
}
pub fn stop(_crosslink_dir: &Path, agent: &str, force: bool) -> Result<()> {
let slug = agent
.strip_prefix("feature/")
.or_else(|| agent.strip_prefix("feat-"))
.unwrap_or(agent);
let wt_slug = slug.rsplit("--").next().unwrap_or(slug);
let session_name = tmux_session_name(wt_slug);
if tmux_session_exists(&session_name) {
let signal = if force { "kill-session" } else { "send-keys" };
if force {
let output = Command::new("tmux")
.args(["kill-session", "-t", &session_name])
.output()
.context("Failed to kill tmux session")?;
if output.status.success() {
println!("Killed tmux session: {session_name}");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("failed to kill session: {}", stderr.trim());
}
} else {
let output = Command::new("tmux")
.args(["send-keys", "-t", &session_name, "C-c", ""])
.output()
.context("Failed to send interrupt to tmux session")?;
if output.status.success() {
println!("Sent interrupt to tmux session: {session_name}");
println!("Use --force to kill immediately.");
}
}
let _ = signal; return Ok(());
}
let container_name = format!("crosslink-agent-{agent}");
for runtime in &["docker", "podman"] {
if command_available(runtime) {
let stop_cmd = if force { "kill" } else { "stop" };
let output = Command::new(runtime)
.args([stop_cmd, &container_name])
.output();
if let Ok(o) = output {
if o.status.success() {
println!("Stopped {runtime} container: {container_name}");
return Ok(());
}
}
}
}
bail!(
"No running agent found for '{agent}'. Checked tmux session '{session_name}' and container '{container_name}'."
);
}
pub(super) fn format_phase_line(name: &str, timing: &PhaseTiming) -> String {
let dur = format_duration(timing.duration_s);
let mut detail = String::new();
if let Some(n) = timing.files_read {
let _ = write!(detail, "{n} files read");
}
if let Some(n) = timing.files_modified {
if !detail.is_empty() {
detail.push_str(", ");
}
let _ = write!(detail, "{n} files");
if let (Some(a), Some(r)) = (timing.lines_added, timing.lines_removed) {
let _ = write!(detail, ", +{a}/-{r} lines");
}
}
if let Some(run) = timing.tests_run {
if !detail.is_empty() {
detail.push_str(", ");
}
let passed = timing.tests_passed.unwrap_or(0);
let _ = write!(detail, "{passed}/{run} passed");
}
if let Some(n) = timing.criteria_checked {
if !detail.is_empty() {
detail.push_str(", ");
}
let _ = write!(detail, "{n} criteria");
}
if let (Some(found), Some(fixed)) = (timing.issues_found, timing.issues_fixed) {
if !detail.is_empty() {
detail.push_str(", ");
}
let _ = write!(detail, "{found} found/{fixed} fixed");
}
if detail.is_empty() {
format!(" {name:<16}{dur}\n")
} else {
format!(" {name:<16}{dur} ({detail})\n")
}
}
pub(crate) fn format_report_table(report: &KickoffReport) -> String {
let mut out = String::new();
out.push_str("Kickoff Report");
if let Some(ref id) = report.agent_id {
let _ = write!(out, ": {id}");
}
out.push('\n');
let mut meta = Vec::new();
if let Some(id) = report.issue_id {
meta.push(format!("Issue: #{id}"));
}
if let Some(ref s) = report.status {
meta.push(format!("Status: {s}"));
}
if let Some(ref phases) = report.phases {
let total: u64 = [
&phases.exploration,
&phases.planning,
&phases.implementation,
&phases.testing,
&phases.validation,
&phases.review,
]
.iter()
.filter_map(|p| p.as_ref().map(|t| t.duration_s))
.sum();
if total > 0 {
meta.push(format!("Duration: {}", format_duration(total)));
}
}
if !meta.is_empty() {
out.push_str(&meta.join(" | "));
out.push('\n');
}
out.push('\n');
if let Some(ref phases) = report.phases {
out.push_str("Phase Timing:\n");
let phase_list: &[(&str, &Option<PhaseTiming>)] = &[
("exploration", &phases.exploration),
("planning", &phases.planning),
("implementation", &phases.implementation),
("testing", &phases.testing),
("validation", &phases.validation),
("review", &phases.review),
];
for (name, timing) in phase_list {
if let Some(t) = timing {
out.push_str(&format_phase_line(name, t));
}
}
out.push('\n');
}
if !report.criteria.is_empty() {
out.push_str("Acceptance Criteria:\n");
for c in &report.criteria {
let symbol = match c.verdict.as_str() {
"pass" => "\u{2713}",
"partial" => "~",
"fail" => "\u{2717}",
"not_applicable" => "-",
_ => "?",
};
let _ = writeln!(out, " {} {} {}", symbol, c.id, c.evidence);
}
out.push('\n');
let s = &report.summary;
let _ = write!(
out,
"{} criteria: {} pass, {} partial, {} fail",
s.total, s.pass, s.partial, s.fail
);
if s.not_applicable > 0 {
let _ = write!(out, ", {} n/a", s.not_applicable);
}
if s.needs_clarification > 0 {
let _ = write!(out, ", {} unclear", s.needs_clarification);
}
out.push('\n');
}
if let Some(ref files) = report.files_changed {
if !files.is_empty() {
let _ = writeln!(out, "\nFiles changed: {}", files.join(", "));
}
}
if let Some(ref commits) = report.commits {
if !commits.is_empty() {
let _ = writeln!(out, "Commits: {}", commits.join(", "));
}
}
out
}
pub(crate) fn format_report_markdown(report: &KickoffReport) -> String {
let mut out = String::new();
out.push_str("## Kickoff Report\n\n");
if let Some(ref id) = report.agent_id {
let _ = writeln!(out, "**Agent**: {id}");
}
if let Some(id) = report.issue_id {
let _ = writeln!(out, "**Issue**: #{id}");
}
if let Some(ref s) = report.status {
let _ = writeln!(out, "**Status**: {s}");
}
out.push('\n');
if !report.criteria.is_empty() {
out.push_str("| ID | Verdict | Evidence |\n");
out.push_str("|---|---|---|\n");
for c in &report.criteria {
let verdict_display = match c.verdict.as_str() {
"pass" => "\u{2713} pass",
"partial" => "~ partial",
"fail" => "\u{2717} fail",
"not_applicable" => "- n/a",
"needs_clarification" => "? unclear",
_ => &c.verdict,
};
let evidence = c.evidence.replace('|', "\\|");
let _ = writeln!(out, "| {} | {} | {} |", c.id, verdict_display, evidence);
}
out.push('\n');
let s = &report.summary;
let _ = writeln!(
out,
"**{} criteria**: {} pass, {} partial, {} fail",
s.total, s.pass, s.partial, s.fail
);
}
out
}
pub(crate) fn format_report_all_table(reports: &[(&str, KickoffReport)]) -> String {
let mut out = String::new();
let _ = writeln!(out, "Agent Kickoff Summary ({} agents)\n", reports.len());
let _ = writeln!(
out,
"{:<32} {:<12} {:<10} {:<14} Duration",
"Agent", "Status", "Tests", "Criteria",
);
let mut completed = 0u32;
let mut failed = 0u32;
for (slug, r) in reports {
let status = r.status.as_deref().unwrap_or("unknown");
match status {
"completed" => completed += 1,
"failed" => failed += 1,
_ => {}
}
let tests = r.phases.as_ref().map_or_else(
|| "-".to_string(),
|phases| {
phases.testing.as_ref().map_or_else(
|| "-".to_string(),
|t| {
let run = t.tests_run.unwrap_or(0);
let passed = t.tests_passed.unwrap_or(0);
format!("{passed}/{run}")
},
)
},
);
let criteria_str = if r.summary.total > 0 {
format!("{}/{} pass", r.summary.pass, r.summary.total)
} else {
"-".to_string()
};
let duration = r.phases.as_ref().map_or_else(
|| "-".to_string(),
|phases| {
let total: u64 = [
&phases.exploration,
&phases.planning,
&phases.implementation,
&phases.testing,
&phases.validation,
&phases.review,
]
.iter()
.filter_map(|p| p.as_ref().map(|t| t.duration_s))
.sum();
if total > 0 {
format_duration(total)
} else {
"-".to_string()
}
},
);
let _ = writeln!(
out,
"{slug:<32} {status:<12} {tests:<10} {criteria_str:<14} {duration}"
);
}
let _ = writeln!(out, "\nTotal: {completed} completed, {failed} failed");
out
}
pub fn report(crosslink_dir: &Path, agent: &str, format: ReportFormat) -> Result<()> {
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let slug = agent
.strip_prefix("feature/")
.or_else(|| agent.strip_prefix("feat-"))
.unwrap_or(agent);
let wt_slug = slug.rsplit("--").next().unwrap_or(slug);
let worktree_dir = root.join(".worktrees").join(wt_slug);
if !worktree_dir.exists() {
bail!(
"No worktree found for '{}'. Checked: {}",
agent,
worktree_dir.display()
);
}
let report_file = worktree_dir.join(".kickoff-report.json");
if !report_file.exists() {
let status_file = worktree_dir.join(".kickoff-status");
let status = if status_file.exists() {
std::fs::read_to_string(&status_file)
.unwrap_or_default()
.trim()
.to_string()
} else {
"still running".to_string()
};
bail!("No validation report found for '{agent}'. Agent status: {status}");
}
let content =
std::fs::read_to_string(&report_file).context("Failed to read .kickoff-report.json")?;
match format {
ReportFormat::Json => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) {
println!(
"{}",
serde_json::to_string_pretty(&parsed).unwrap_or(content)
);
} else {
print!("{content}");
}
}
ReportFormat::Table => {
let r: KickoffReport =
serde_json::from_str(&content).context("Failed to parse .kickoff-report.json")?;
for w in validate_kickoff_report(&r) {
tracing::warn!("{}", w);
}
print!("{}", format_report_table(&r));
}
ReportFormat::Markdown => {
let r: KickoffReport =
serde_json::from_str(&content).context("Failed to parse .kickoff-report.json")?;
print!("{}", format_report_markdown(&r));
}
}
Ok(())
}
pub fn report_all(crosslink_dir: &Path, format: ReportFormat) -> Result<()> {
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let worktrees_dir = root.join(".worktrees");
if !worktrees_dir.is_dir() {
bail!("No .worktrees directory found");
}
let mut reports: Vec<(String, KickoffReport)> = Vec::new();
for entry in std::fs::read_dir(&worktrees_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let report_file = entry.path().join(".kickoff-report.json");
if !report_file.exists() {
continue;
}
let Ok(content) = std::fs::read_to_string(&report_file) else {
continue;
};
let r: KickoffReport = match serde_json::from_str(&content) {
Ok(r) => r,
Err(_) => continue,
};
let slug = entry.file_name().to_string_lossy().to_string();
reports.push((slug, r));
}
if reports.is_empty() {
bail!("No kickoff reports found in any worktree");
}
match format {
ReportFormat::Json => {
let json_reports: Vec<_> = reports.iter().map(|(_, r)| r).collect();
println!("{}", serde_json::to_string_pretty(&json_reports)?);
}
ReportFormat::Table => {
let refs: Vec<(&str, KickoffReport)> = reports
.iter()
.map(|(s, r)| (s.as_str(), r.clone()))
.collect();
print!("{}", format_report_all_table(&refs));
}
ReportFormat::Markdown => {
let refs: Vec<(&str, KickoffReport)> = reports
.iter()
.map(|(s, r)| (s.as_str(), r.clone()))
.collect();
print!("{}", format_report_all_table(&refs));
}
}
Ok(())
}