use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::identity::AgentConfig;
use super::helpers::*;
use super::types::*;
fn resolve_timeout_command(platform: &Platform) -> Result<&'static str> {
if command_available("timeout") {
return Ok("timeout");
}
if command_available("gtimeout") {
return Ok("gtimeout");
}
bail!(
"Neither `timeout` nor `gtimeout` found.\n{}",
install_hint("timeout", platform)
);
}
pub(super) fn read_sandbox_command(crosslink_dir: &Path) -> Option<String> {
let config_path = crosslink_dir.join("hook-config.json");
let content = std::fs::read_to_string(&config_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
parsed
.get("sandbox")
.and_then(|s| s.get("command"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
pub(super) fn read_watchdog_config(crosslink_dir: &Path) -> WatchdogConfig {
let config_path = crosslink_dir.join("hook-config.json");
let Ok(content) = std::fs::read_to_string(&config_path) else {
return WatchdogConfig::default();
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
return WatchdogConfig::default();
};
let Some(wd) = parsed.get("watchdog") else {
return WatchdogConfig::default();
};
let mut cfg = WatchdogConfig::default();
if let Some(v) = wd.get("enabled").and_then(serde_json::Value::as_bool) {
cfg.enabled = v;
}
if let Some(v) = wd.get("staleness_secs").and_then(serde_json::Value::as_u64) {
cfg.staleness_secs = v;
}
if let Some(v) = wd.get("max_nudges").and_then(serde_json::Value::as_u64) {
cfg.max_nudges = u32::try_from(v).unwrap_or(u32::MAX);
}
if let Some(v) = wd
.get("check_interval_secs")
.and_then(serde_json::Value::as_u64)
{
cfg.check_interval_secs = v;
}
if let Some(v) = wd
.get("grace_period_secs")
.and_then(serde_json::Value::as_u64)
{
cfg.grace_period_secs = v;
}
cfg
}
pub(super) fn build_watchdog_script(
session_name: &str,
worktree_dir: &Path,
cfg: &WatchdogConfig,
) -> String {
format!(
r#"NUDGES=0
sleep {grace}
while true; do
sleep {interval}
if [ -f "{worktree}/.kickoff-status" ]; then exit 0; fi
if ! tmux has-session -t "{session}" 2>/dev/null; then exit 0; fi
HB="{worktree}/.crosslink/.cache/last-heartbeat"
if [ -f "$HB" ]; then
LAST=$(stat -c %Y "$HB" 2>/dev/null || stat -f %m "$HB" 2>/dev/null)
NOW=$(date +%s)
AGE=$((NOW - LAST))
if [ "$AGE" -gt {staleness} ]; then
if [ "$NUDGES" -ge {max_nudges} ]; then exit 1; fi
NUDGES=$((NUDGES + 1))
tmux send-keys -t "{session}" "continue working, the task is not yet complete" Enter
fi
fi
done
"#,
grace = cfg.grace_period_secs,
interval = cfg.check_interval_secs,
worktree = worktree_dir.display(),
session = session_name,
staleness = cfg.staleness_secs,
max_nudges = cfg.max_nudges,
)
}
pub(super) fn spawn_watchdog(
session_name: &str,
worktree_dir: &Path,
cfg: &WatchdogConfig,
) -> Result<()> {
let script = build_watchdog_script(session_name, worktree_dir, cfg);
Command::new("bash")
.args(["-c", &script])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("Failed to spawn watchdog process")?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_agent_command(
timeout_cmd: &str,
timeout_secs: u64,
model: &str,
allowed_tools: &str,
kickoff_file: &str,
sandbox_command: Option<&str>,
worktree_dir: &Path,
skip_permissions: bool,
claude_config_dir: Option<&str>,
) -> String {
use crate::utils::shell_escape_arg;
let skip_flag = if skip_permissions {
" --dangerously-skip-permissions"
} else {
""
};
let env_prefix = claude_config_dir
.filter(|v| !v.is_empty())
.map(|v| format!("CLAUDE_CONFIG_DIR={} ", shell_escape_arg(v)))
.unwrap_or_default();
let escaped_model = shell_escape_arg(model);
let escaped_tools = shell_escape_arg(allowed_tools);
let escaped_kickoff = shell_escape_arg(kickoff_file);
let claude_cmd = format!(
"{env_prefix}env -u CLAUDECODE claude{skip_flag} --model {escaped_model} --allowedTools {escaped_tools} -- \"$(cat {escaped_kickoff})\""
);
sandbox_command.map_or_else(
|| format!("{timeout_cmd} {timeout_secs}s {claude_cmd}"),
|cmd| {
let escaped_worktree = shell_escape_arg(&worktree_dir.to_string_lossy());
let expanded = cmd.replace("{{worktree}}", &escaped_worktree);
format!("{timeout_cmd} {timeout_secs}s {expanded} {claude_cmd}")
},
)
}
pub(super) fn preflight_check(
container: &ContainerMode,
verify: &VerifyLevel,
crosslink_dir: &Path,
) -> Result<PreflightResult> {
let platform = detect_platform();
let mut missing: Vec<String> = Vec::new();
let timeout_cmd = match resolve_timeout_command(&platform) {
Ok(cmd) => cmd,
Err(e) => {
missing.push(format!("{e}"));
"timeout" }
};
if *container == ContainerMode::None {
if cfg!(target_os = "windows") {
bail!(
"Local kickoff mode requires tmux, which is not available on Windows.\n\
Use `--container docker` for agent kickoff on Windows."
);
}
if !command_available("tmux") {
missing.push(install_hint("tmux", &platform));
}
}
if *container == ContainerMode::None && !command_available("claude") {
missing.push(install_hint("claude", &platform));
}
if (*verify == VerifyLevel::Ci || *verify == VerifyLevel::Thorough) && !command_available("gh")
{
missing.push(install_hint("gh", &platform));
}
match container {
ContainerMode::Docker if !command_available("docker") => {
missing.push(install_hint("docker", &platform));
}
ContainerMode::Podman if !command_available("podman") => {
missing.push(install_hint("podman", &platform));
}
_ => {}
}
let sandbox_command = read_sandbox_command(crosslink_dir);
if let Some(ref cmd) = sandbox_command {
let binary = cmd.split_whitespace().next().unwrap_or(cmd);
if !command_available(binary) {
missing.push(format!(
"`{binary}` (configured in hook-config.json sandbox.command) not found on PATH"
));
}
}
if !missing.is_empty() {
let header = format!(
"Pre-flight check failed — {} missing command{}:\n",
missing.len(),
if missing.len() == 1 { "" } else { "s" }
);
let body = missing
.iter()
.enumerate()
.map(|(i, msg)| format!("{}. {}", i + 1, msg))
.collect::<Vec<_>>()
.join("\n\n");
bail!("{header}{body}");
}
Ok(PreflightResult {
timeout_cmd,
sandbox_command,
})
}
pub(super) fn repo_root() -> Result<std::path::PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
bail!("Not inside a git repository");
}
let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
let toplevel_path = std::path::PathBuf::from(&toplevel);
Ok(crate::utils::resolve_main_repo_root(&toplevel_path).unwrap_or(toplevel_path))
}
pub(super) fn create_worktree(
repo_root: &Path,
slug: &str,
base_branch: Option<&str>,
) -> Result<(std::path::PathBuf, String)> {
let branch_name = format!("feature/{slug}");
let worktree_dir = repo_root.join(".worktrees").join(slug);
let canonical_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
for forbidden in [".crosslink", ".git"] {
let forbidden_dir = canonical_root.join(forbidden);
if let Ok(canonical_wt) = worktree_dir.canonicalize() {
if canonical_wt.starts_with(&forbidden_dir) {
bail!(
"Worktree path {} would land inside {}/. \
This usually means repo_root resolved to an internal directory. \
Please run this command from the main repository root.",
worktree_dir.display(),
forbidden
);
}
}
}
if worktree_dir.exists() {
bail!(
"Worktree already exists at {}. Remove it first or use --branch to target an existing branch.",
worktree_dir.display()
);
}
let base = base_branch.unwrap_or("HEAD");
let branch_exists = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "--verify", &branch_name])
.output()
.is_ok_and(|o| o.status.success());
if branch_exists {
let wt_output = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "list", "--porcelain"])
.output()
.context("Failed to list worktrees")?;
let wt_list = String::from_utf8_lossy(&wt_output.stdout);
let has_active_worktree = wt_list
.lines()
.any(|line| line.starts_with("branch ") && line.ends_with(&branch_name));
if has_active_worktree {
bail!(
"Branch '{branch_name}' already exists and has an active worktree. \
Clean up the worktree first with: git worktree remove <path>"
);
}
let is_merged = Command::new("git")
.current_dir(repo_root)
.args(["merge-base", "--is-ancestor", &branch_name, base])
.output()
.is_ok_and(|o| o.status.success());
if is_merged {
tracing::info!(
"branch '{}' exists from a prior phase and is fully merged, recreating",
branch_name
);
let delete_output = Command::new("git")
.current_dir(repo_root)
.args(["branch", "-d", &branch_name])
.output()
.context("Failed to delete merged branch")?;
if !delete_output.status.success() {
let stderr = String::from_utf8_lossy(&delete_output.stderr);
bail!(
"Branch '{}' is merged but could not be deleted: {}",
branch_name,
stderr.trim()
);
}
} else {
bail!(
"Branch '{branch_name}' already exists and has unmerged changes. \
Either merge it first, delete it manually with \
`git branch -D {branch_name}`, or use a different slug."
);
}
}
let output = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "add", "-b", &branch_name])
.arg(&worktree_dir)
.arg(base)
.output()
.context("Failed to create git worktree")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create worktree: {}", stderr.trim());
}
Ok((worktree_dir, branch_name))
}
pub(super) fn init_worktree_agent(
worktree_dir: &Path,
crosslink_dir: &Path,
compact_name: &str,
) -> Result<String> {
let output = Command::new("crosslink")
.current_dir(worktree_dir)
.args(["init", "--force", "--skip-signing", "--defaults"])
.output()
.context("Failed to run crosslink init in worktree")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("crosslink init in worktree: {}", stderr.trim());
}
let agent_id = compact_name.to_string();
let wt_crosslink = worktree_dir.join(".crosslink");
if wt_crosslink.exists() {
if AgentConfig::load(&wt_crosslink)?.is_none() {
if let Err(e) = super::super::agent::init(
&wt_crosslink,
&agent_id,
Some(&format!("Kickoff agent for: {compact_name}")),
false, false,
) {
tracing::warn!("could not initialize agent identity in worktree: {e} — agent will work without its own identity");
}
if let Err(e) = super::super::trust::approve(crosslink_dir, &agent_id) {
tracing::warn!(
"could not auto-approve agent '{}': {e} — run `crosslink trust approve {}` manually",
agent_id, agent_id
);
}
}
}
let output = Command::new("crosslink")
.current_dir(worktree_dir)
.args(["sync"])
.output();
if let Ok(o) = output {
if !o.status.success() {
tracing::warn!("crosslink sync in worktree returned non-zero");
}
}
Ok(agent_id)
}
pub(super) fn exclude_kickoff_files(worktree_dir: &Path) -> Result<()> {
let output = Command::new("git")
.current_dir(worktree_dir)
.args(["rev-parse", "--git-common-dir"])
.output()
.context("Failed to get git common dir")?;
let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let exclude_path = std::path::PathBuf::from(&common_dir).join("info/exclude");
if let Some(parent) = exclude_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default();
let additions = missing_exclude_patterns(&existing);
if !additions.is_empty() {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude_path)
.context("Failed to open git exclude file")?;
for pattern in additions {
writeln!(file, "{pattern}")?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn launch_local(
worktree_dir: &Path,
session_name: &str,
model: &str,
allowed_tools: &str,
timeout: Duration,
timeout_cmd: &str,
sandbox_command: Option<&str>,
crosslink_dir: &Path,
skip_permissions: bool,
) -> Result<()> {
let output = Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
session_name,
"-c",
&worktree_dir.to_string_lossy(),
])
.output()
.context("Failed to create tmux session")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create tmux session: {}", stderr.trim());
}
let claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok();
let cmd = build_agent_command(
timeout_cmd,
timeout.as_secs(),
model,
allowed_tools,
"KICKOFF.md",
sandbox_command,
worktree_dir,
skip_permissions,
claude_config_dir.as_deref(),
);
std::fs::write(worktree_dir.join(".kickoff-status"), "LAUNCHING\n")
.context("Failed to write initial .kickoff-status")?;
let output = Command::new("tmux")
.args(["send-keys", "-t", session_name, &cmd, "Enter"])
.output()
.context("Failed to send command to tmux session")?;
if !output.status.success() {
let _ = std::fs::write(worktree_dir.join(".kickoff-status"), "FAILED\n");
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to send keys to tmux: {}", stderr.trim());
}
let _ = std::fs::write(worktree_dir.join(".kickoff-status"), "RUNNING\n");
let watchdog_cfg = read_watchdog_config(crosslink_dir);
if watchdog_cfg.enabled {
if let Err(e) = spawn_watchdog(session_name, worktree_dir, &watchdog_cfg) {
tracing::warn!("failed to spawn watchdog: {}", e);
}
}
Ok(())
}
pub(super) fn launch_container(
runtime: &ContainerMode,
worktree_dir: &Path,
image: &str,
agent_id: &str,
model: &str,
allowed_tools: &str,
timeout: Duration,
) -> Result<String> {
let runtime_cmd = match runtime {
ContainerMode::Docker => "docker",
ContainerMode::Podman => "podman",
ContainerMode::None => unreachable!(),
};
if !command_available(runtime_cmd) {
bail!("{runtime_cmd} is not installed. Install it or use --container none for local mode.");
}
let timeout_secs = timeout.as_secs();
let container_name = format!("crosslink-agent-{agent_id}");
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let host_auth = format!("{home}/.claude");
let uid_gid = if cfg!(target_os = "windows") {
None
} else {
let uid = Command::new("id").arg("-u").output().map_or_else(
|_| "1000".to_string(),
|o| String::from_utf8_lossy(&o.stdout).trim().to_string(),
);
let gid = Command::new("id").arg("-g").output().map_or_else(
|_| "1000".to_string(),
|o| String::from_utf8_lossy(&o.stdout).trim().to_string(),
);
Some((uid, gid))
};
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
container_name,
"--stop-timeout".to_string(),
format!("{}", timeout_secs),
"-v".to_string(),
format!("{}:/workspaces/repo", worktree_dir.to_string_lossy()),
"-v".to_string(),
format!("{}:/host-auth:ro", host_auth),
"-e".to_string(),
format!("AGENT_ID={}", agent_id),
];
if let Some((uid, gid)) = &uid_gid {
args.extend([
"-e".to_string(),
format!("HOST_UID={uid}"),
"-e".to_string(),
format!("HOST_GID={gid}"),
]);
}
args.push(image.to_string());
args.push("bash".to_string());
args.push("-c".to_string());
args.push(format!(
"cd /workspaces/repo && timeout {timeout_secs}s claude --model {model} --allowedTools '{allowed_tools}' -- \"$(cat KICKOFF.md)\""
));
let output = Command::new(runtime_cmd)
.args(&args)
.output()
.with_context(|| format!("Failed to launch {runtime_cmd} container"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{} container launch failed: {}", runtime_cmd, stderr.trim());
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(container_id)
}