use crate::common::TimeoutError;
use crate::domain::ItemState;
use crate::ffi::FFIBoundary;
use crate::{GithubOwner, GithubRepo, IssueNumber};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
use super::git::GitService;
use super::github::{GitHubService, Repo};
use super::local::LocalExecutor;
use super::zellij_events;
const SPAWN_TIMEOUT: Duration = Duration::from_secs(60);
const GIT_TIMEOUT: Duration = Duration::from_secs(30);
const ZELLIJ_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum AgentType {
Claude,
#[default]
Gemini,
}
struct AgentMetadata {
command: &'static str,
prompt_flag: &'static str,
suffix: &'static str,
emoji: &'static str,
}
const CLAUDE_META: AgentMetadata = AgentMetadata {
command: "claude",
prompt_flag: "--prompt",
suffix: "claude",
emoji: "\u{1F916}", };
const GEMINI_META: AgentMetadata = AgentMetadata {
command: "gemini",
prompt_flag: "--prompt-interactive",
suffix: "gemini",
emoji: "\u{1F48E}", };
impl AgentType {
fn meta(&self) -> &'static AgentMetadata {
match self {
AgentType::Claude => &CLAUDE_META,
AgentType::Gemini => &GEMINI_META,
}
}
fn command(&self) -> &'static str {
self.meta().command
}
fn prompt_flag(&self) -> &'static str {
self.meta().prompt_flag
}
fn suffix(&self) -> &'static str {
self.meta().suffix
}
fn emoji(&self) -> &'static str {
self.meta().emoji
}
fn display_name(&self, issue_id: &str, slug: &str) -> String {
let short_slug: String = slug.chars().take(20).collect();
format!("{} gh-{}-{}", self.emoji(), issue_id, short_slug)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnOptions {
pub owner: GithubOwner,
pub repo: GithubRepo,
pub worktree_dir: Option<String>,
#[serde(default)]
pub agent_type: AgentType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpawnResult {
pub worktree_path: String,
pub branch_name: String,
pub tab_name: String,
pub issue_title: String,
pub agent_type: String,
}
impl FFIBoundary for SpawnResult {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentPrInfo {
pub number: u64,
pub title: String,
pub url: String,
pub state: ItemState,
}
impl FFIBoundary for AgentPrInfo {}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AgentStatus {
Running,
OrphanWorktree,
OrphanTab,
}
impl FFIBoundary for AgentStatus {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentInfo {
pub issue_id: String,
pub has_tab: bool,
pub has_worktree: bool,
pub has_changes: Option<bool>,
pub has_unpushed: Option<bool>,
pub status: AgentStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr: Option<AgentPrInfo>,
}
impl FFIBoundary for AgentInfo {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BatchSpawnResult {
pub spawned: Vec<SpawnResult>,
pub failed: Vec<(String, String)>, }
impl FFIBoundary for BatchSpawnResult {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BatchCleanupResult {
pub cleaned: Vec<String>,
pub failed: Vec<(String, String)>, }
impl FFIBoundary for BatchCleanupResult {}
pub struct AgentControlService {
project_dir: PathBuf,
github: Option<GitHubService>,
git: GitService,
zellij_session: Option<String>,
}
impl AgentControlService {
pub fn new(project_dir: PathBuf, github: Option<GitHubService>, git: GitService) -> Self {
Self {
project_dir,
github,
git,
zellij_session: None,
}
}
pub fn with_zellij_session(mut self, session: String) -> Self {
self.zellij_session = Some(session);
self
}
pub fn from_env() -> Result<Self> {
let project_dir = std::env::current_dir().context("Failed to get current directory")?;
let secrets = super::secrets::Secrets::load();
let github = secrets
.github_token()
.and_then(|t| GitHubService::new(t).ok());
let git = GitService::new(Arc::new(LocalExecutor::new()));
Ok(Self {
project_dir,
github,
git,
zellij_session: None,
})
}
#[tracing::instrument(skip(self, options), fields(issue_id = %issue_number.as_u64()))]
pub async fn spawn_agent(
&self,
issue_number: IssueNumber,
options: &SpawnOptions,
) -> Result<SpawnResult> {
let issue_id_log = issue_number.as_u64().to_string();
info!(issue_id = %issue_id_log, timeout_sec = SPAWN_TIMEOUT.as_secs(), "Starting spawn_agent");
let result = timeout(SPAWN_TIMEOUT, async {
self.check_zellij_env()?;
let github = self
.github
.as_ref()
.ok_or_else(|| anyhow!("GitHub service not available (GITHUB_TOKEN not set)"))?;
let issue_id = issue_number.as_u64().to_string();
info!(issue_id, "Fetching issue from GitHub");
let repo = Repo {
owner: options.owner.clone(),
name: options.repo.clone(),
};
let issue = github.get_issue(&repo, issue_number.as_u64()).await?;
let slug = slugify(&issue.title);
let agent_suffix = options.agent_type.suffix();
let worktree_dir = options
.worktree_dir
.clone()
.unwrap_or_else(|| ".exomonad/worktrees".to_string());
let worktree_path = self
.project_dir
.join(&worktree_dir)
.join(format!("gh-{}-{}-{}", issue_id, slug, agent_suffix));
let branch_name = format!("gh-{}/{}-{}", issue_id, slug, agent_suffix);
self.fetch_origin().await?;
self.create_worktree(&worktree_path, &branch_name).await?;
self.write_context_files(&worktree_path, options.agent_type)
.await?;
let issue_url = format!(
"https://github.com/{}/{}/issues/{}",
options.owner, options.repo, issue_id
);
let initial_prompt = Self::build_initial_prompt(
&issue_id,
&issue.title,
&issue.body,
&issue.labels,
&branch_name,
&issue_url,
);
tracing::info!(
issue_id,
prompt_length = initial_prompt.len(),
"Built initial prompt for agent"
);
let internal_name = format!("gh-{}-{}-{}", issue_id, slug, agent_suffix);
let display_name = options.agent_type.display_name(&issue_id, &slug);
self.new_zellij_tab(
&display_name,
&worktree_path,
options.agent_type,
Some(&initial_prompt),
)
.await?;
if let Some(ref session) = self.zellij_session {
let agent_id = crate::ui_protocol::AgentId::try_from(internal_name.clone())
.map_err(|e| anyhow!("Invalid agent_id: {}", e))?;
let event = crate::ui_protocol::AgentEvent::AgentStarted {
agent_id,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(session, &event) {
warn!("Failed to emit agent:started event: {}", e);
}
}
Ok::<SpawnResult, anyhow::Error>(SpawnResult {
worktree_path: worktree_path.to_string_lossy().to_string(),
branch_name,
tab_name: internal_name,
issue_title: issue.title,
agent_type: options.agent_type.suffix().to_string(),
})
})
.await
.map_err(|_| {
let msg = format!("spawn_agent timed out after {}s", SPAWN_TIMEOUT.as_secs());
warn!(issue_id = %issue_id_log, error = %msg, "spawn_agent timed out");
anyhow::Error::new(TimeoutError { message: msg })
})??;
info!(issue_id = %issue_id_log, "spawn_agent completed successfully");
Ok(result)
}
#[tracing::instrument(skip(self, options))]
pub async fn spawn_agents(
&self,
issue_ids: &[String],
options: &SpawnOptions,
) -> BatchSpawnResult {
let mut result = BatchSpawnResult {
spawned: Vec::new(),
failed: Vec::new(),
};
for issue_id_str in issue_ids {
match IssueNumber::try_from(issue_id_str.clone()) {
Ok(issue_number) => match self.spawn_agent(issue_number, options).await {
Ok(spawn_result) => result.spawned.push(spawn_result),
Err(e) => {
warn!(issue_id = issue_id_str, error = %e, "Failed to spawn agent");
result.failed.push((issue_id_str.clone(), e.to_string()));
}
},
Err(e) => {
warn!(issue_id = issue_id_str, error = %e, "Invalid issue number");
result.failed.push((issue_id_str.clone(), e.to_string()));
}
}
}
result
}
#[tracing::instrument(skip(self))]
pub async fn cleanup_agent(&self, issue_id: &str, force: bool) -> Result<()> {
let agents = self.list_agents().await?;
let prefix = format!("gh-{}-", issue_id);
let mut found = false;
for agent in agents {
if let Some(ref worktree_path) = agent.worktree_path {
let path = Path::new(worktree_path);
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with(&prefix) {
found = true;
if let Some(parsed) = parse_worktree_name(name) {
match parsed.agent_type {
Some(agent_type) => {
let display_name =
agent_type.display_name(issue_id, parsed.slug);
if let Err(e) = self.close_zellij_tab(&display_name).await {
warn!(tab_name = %display_name, error = %e, "Failed to close Zellij tab (may not exist)");
}
}
None => {
warn!(
worktree_name = %name,
"Unknown agent suffix in worktree name; skipping Zellij tab close"
);
}
}
}
self.delete_worktree(path, force).await?;
self.prune_worktrees().await?;
}
}
}
}
if found {
if let Some(ref session) = self.zellij_session {
let agent_id = crate::ui_protocol::AgentId::try_from(format!("gh-{}", issue_id))
.map_err(|e| anyhow!("Invalid agent_id: {}", e))?;
let event = crate::ui_protocol::AgentEvent::AgentStopped {
agent_id,
timestamp: zellij_events::now_iso8601(),
};
if let Err(e) = zellij_events::emit_event(session, &event) {
warn!("Failed to emit agent:stopped event: {}", e);
}
}
Ok(())
} else {
Err(anyhow!("No worktree found for issue {}", issue_id))
}
}
#[tracing::instrument(skip(self))]
pub async fn cleanup_agents(&self, issue_ids: &[String], force: bool) -> BatchCleanupResult {
let mut result = BatchCleanupResult {
cleaned: Vec::new(),
failed: Vec::new(),
};
for issue_id in issue_ids {
match self.cleanup_agent(issue_id, force).await {
Ok(()) => result.cleaned.push(issue_id.clone()),
Err(e) => {
warn!(issue_id, error = %e, "Failed to cleanup agent");
result.failed.push((issue_id.clone(), e.to_string()));
}
}
}
result
}
#[tracing::instrument(skip(self))]
pub async fn cleanup_merged_agents(&self) -> Result<BatchCleanupResult> {
self.fetch_origin().await?;
let agents = self.list_agents().await?;
let mut to_cleanup = Vec::new();
for agent in agents {
if let Some(ref branch_name) = agent.branch_name {
if self.is_branch_merged(branch_name).await.unwrap_or(false) {
info!(issue_id = %agent.issue_id, branch = %branch_name, "Branch is merged, marking for cleanup");
to_cleanup.push(agent.issue_id);
}
}
}
if to_cleanup.is_empty() {
return Ok(BatchCleanupResult {
cleaned: Vec::new(),
failed: Vec::new(),
});
}
Ok(self.cleanup_agents(&to_cleanup, false).await)
}
#[tracing::instrument(skip(self))]
pub async fn list_agents(&self) -> Result<Vec<AgentInfo>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git worktree list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git worktree list failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut worktrees = HashMap::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(path));
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch.to_string());
} else if line.is_empty() {
if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
let name = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
if let Some(name) = name {
if name.starts_with("gh-") {
if let Some(parsed) = parse_worktree_name(&name) {
worktrees.insert(
parsed.issue_id.to_string(),
(path, branch, name.to_string()),
);
} else {
let parts: Vec<&str> = name.splitn(4, '-').collect();
if let Some(issue_id) = parts.get(1) {
worktrees.insert(
issue_id.to_string(),
(path, branch, name.to_string()),
);
}
}
}
}
}
}
}
let zellij_tabs = self.get_zellij_tabs().await.unwrap_or_default();
let mut tabs = HashSet::new();
for tab_name in zellij_tabs {
if let Some(issue_id) = self.extract_issue_id_from_tab_name(&tab_name) {
tabs.insert(issue_id);
}
}
let mut all_issues: HashSet<String> = worktrees.keys().cloned().collect();
all_issues.extend(tabs.iter().cloned());
let mut agents = Vec::new();
for issue_id in all_issues {
let has_worktree = worktrees.contains_key(&issue_id);
let has_tab = tabs.contains(&issue_id);
let status = match (has_tab, has_worktree) {
(true, true) => AgentStatus::Running,
(false, true) => AgentStatus::OrphanWorktree,
(true, false) => AgentStatus::OrphanTab,
(false, false) => continue, };
let mut agent = AgentInfo {
issue_id: issue_id.clone(),
has_tab,
has_worktree,
status,
worktree_path: None,
branch_name: None,
has_changes: None,
has_unpushed: None,
slug: None,
agent_type: None,
pr: None,
};
if let Some((path, branch, name)) = worktrees.get(&issue_id) {
agent.worktree_path = Some(path.to_string_lossy().to_string());
agent.branch_name = Some(branch.clone());
agent.has_changes = Some(self.has_uncommitted_changes(path).await);
agent.has_unpushed = Some(
self.git
.has_unpushed_commits(&path.to_string_lossy())
.await
.unwrap_or(0)
> 0,
);
if let Some(parsed) = parse_worktree_name(name) {
agent.slug = Some(parsed.slug.to_string());
agent.agent_type = parsed.agent_type.map(|t| t.suffix().to_string());
}
}
agents.push(agent);
}
if let Some(ref github) = self.github {
if let Some(repo) = self.get_repo_from_remote().await {
for agent in &mut agents {
if let Some(ref branch) = agent.branch_name {
if let Ok(Some(pr)) = github.get_pr_for_branch(&repo, branch).await {
agent.pr = Some(AgentPrInfo {
number: pr.number,
title: pr.title,
url: pr.url,
state: pr.state,
});
}
}
}
}
}
Ok(agents)
}
async fn get_repo_from_remote(&self) -> Option<Repo> {
let dir = self.project_dir.to_string_lossy();
let repo_info = self.git.get_repo_info(&dir).await.ok()?;
let owner = repo_info.owner?;
let name = repo_info.name?;
Some(Repo {
owner: GithubOwner::from(owner.as_str()),
name: GithubRepo::from(name.as_str()),
})
}
fn check_zellij_env(&self) -> Result<String> {
std::env::var("ZELLIJ_SESSION_NAME")
.context("Not running inside a Zellij session (ZELLIJ_SESSION_NAME not set)")
}
#[tracing::instrument(skip(self, prompt))]
async fn new_zellij_tab(
&self,
name: &str,
cwd: &Path,
agent_type: AgentType,
prompt: Option<&str>,
) -> Result<()> {
info!(name, cwd = %cwd.display(), agent_type = ?agent_type, "Creating Zellij tab");
let cmd = agent_type.command();
let agent_command = match prompt {
Some(p) => {
let escaped_prompt = Self::escape_for_shell_command(p);
debug!(
tab_name = name,
agent_type = ?agent_type,
prompt_length = p.len(),
"Spawning agent with CLI prompt"
);
format!("{} {} {}", cmd, agent_type.prompt_flag(), escaped_prompt)
}
None => cmd.to_string(),
};
let full_command = format!("nix develop -c {}", agent_command);
let kdl_escaped_command = Self::escape_for_kdl(&full_command);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let params = crate::layout::AgentTabParams {
tab_name: name,
pane_name: "Agent",
command: &kdl_escaped_command,
cwd,
shell: &shell,
focus: true,
close_on_exit: true,
};
let layout_content = crate::layout::generate_agent_layout(¶ms)
.context("Failed to generate Zellij layout")?;
let temp_dir = std::env::temp_dir();
let safe_filename: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let layout_file = temp_dir.join(format!("zellij-tab-{}.kdl", safe_filename));
tokio::fs::write(&layout_file, &layout_content)
.await
.context("Failed to write temporary layout file")?;
debug!(
layout_file = %layout_file.display(),
"Generated temporary Zellij layout"
);
debug!(
name,
layout_file = %layout_file.display(),
"Executing zellij action new-tab"
);
let output = Command::new("zellij")
.args([
"action",
"new-tab",
"--layout",
layout_file
.to_str()
.ok_or_else(|| anyhow!("Invalid layout file path (utf8 error)"))?,
])
.output()
.await
.context("Failed to run zellij")?;
let _ = tokio::fs::remove_file(&layout_file).await;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"zellij new-tab failed with status: {} (stderr: {}, layout file was: {})",
output.status,
stderr,
layout_file.display()
));
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn get_zellij_tabs(&self) -> Result<Vec<String>> {
debug!("Querying Zellij tab names");
let output = Command::new("zellij")
.args(["action", "query-tab-names"])
.output()
.await
.context("Failed to execute zellij action query-tab-names")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(stderr = %stderr, "zellij query-tab-names failed, assuming no tabs");
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|s| s.to_string()).collect())
}
fn extract_issue_id_from_tab_name(&self, name: &str) -> Option<String> {
let parts: Vec<&str> = name.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let identifier = parts[1];
if let Some(rest) = identifier.strip_prefix("gh-") {
rest.split('-').next().map(|s| s.to_string())
} else {
identifier.split('-').next().map(|s| s.to_string())
}
}
async fn close_zellij_tab(&self, name: &str) -> Result<()> {
info!(name, "Running zellij action close-tab");
let result = timeout(ZELLIJ_TIMEOUT, async {
Command::new("zellij")
.args(["action", "close-tab", "--tab-name", name])
.output()
.await
.context("Failed to execute zellij")
})
.await
.map_err(|_| {
anyhow::Error::new(TimeoutError {
message: format!(
"zellij close-tab timed out after {}s",
ZELLIJ_TIMEOUT.as_secs()
),
})
})??;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!(stderr = %stderr, exit_code = ?result.status.code(), "zellij close-tab failed");
return Err(anyhow!("zellij close-tab failed: {}", stderr));
} else {
info!(exit_code = ?result.status.code(), "zellij close-tab successful");
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn fetch_origin(&self) -> Result<()> {
info!("Running git fetch origin main");
let result = timeout(GIT_TIMEOUT, async {
Command::new("git")
.args(["fetch", "origin", "main"])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git fetch")
})
.await
.map_err(|_| {
anyhow::Error::new(TimeoutError {
message: format!("git fetch timed out after {}s", GIT_TIMEOUT.as_secs()),
})
})??;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!(stderr = %stderr, exit_code = ?result.status.code(), "git fetch warning (continuing anyway)");
} else {
info!(exit_code = ?result.status.code(), "git fetch successful");
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn create_worktree(&self, path: &Path, branch: &str) -> Result<()> {
if path.exists() {
info!(path = %path.display(), "Worktree already exists, reusing");
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create worktree parent directory")?;
}
info!(path = %path.display(), branch, "Running git worktree add");
let result = timeout(GIT_TIMEOUT, async {
Command::new("git")
.args([
"worktree",
"add",
"-b",
branch,
path.to_str()
.ok_or_else(|| anyhow!("Invalid worktree path (utf8 error)"))?,
"origin/main",
])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git worktree add")
})
.await
.map_err(|_| {
anyhow::Error::new(TimeoutError {
message: format!(
"git worktree add timed out after {}s",
GIT_TIMEOUT.as_secs()
),
})
})??;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!(stderr = %stderr, exit_code = ?result.status.code(), "git worktree add failed, checking if branch exists");
if stderr.contains("already exists") {
debug!("Branch already exists, creating worktree without -b");
let fallback_result = timeout(GIT_TIMEOUT, async {
Command::new("git")
.args([
"worktree",
"add",
path.to_str()
.ok_or_else(|| anyhow!("Invalid worktree path (utf8 error)"))?,
branch,
])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git worktree add (fallback)")
})
.await
.map_err(|_| {
anyhow::Error::new(TimeoutError {
message: format!(
"git worktree add (fallback) timed out after {}s",
GIT_TIMEOUT.as_secs()
),
})
})??;
if !fallback_result.status.success() {
let stderr = String::from_utf8_lossy(&fallback_result.stderr);
warn!(stderr = %stderr, exit_code = ?fallback_result.status.code(), "git worktree add (fallback) failed");
return Err(anyhow!("git worktree add failed: {}", stderr));
}
info!(exit_code = ?fallback_result.status.code(), "git worktree add (fallback) successful");
} else {
return Err(anyhow!("git worktree add failed: {}", stderr));
}
} else {
info!(exit_code = ?result.status.code(), "git worktree add successful");
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn delete_worktree(&self, path: &Path, force: bool) -> Result<()> {
if !path.exists() {
debug!(path = %path.display(), "Worktree doesn't exist");
return Ok(());
}
info!(path = %path.display(), force, "Running git worktree remove");
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(
path.to_str()
.ok_or_else(|| anyhow!("Invalid worktree path (utf8 error)"))?,
);
let result = timeout(GIT_TIMEOUT, async {
Command::new("git")
.args(&args)
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git worktree remove")
})
.await
.map_err(|_| {
anyhow::Error::new(TimeoutError {
message: format!(
"git worktree remove timed out after {}s",
GIT_TIMEOUT.as_secs()
),
})
})??;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!(stderr = %stderr, exit_code = ?result.status.code(), "git worktree remove failed");
return Err(anyhow!("git worktree remove failed: {}", stderr));
} else {
info!(exit_code = ?result.status.code(), "git worktree remove successful");
}
Ok(())
}
async fn prune_worktrees(&self) -> Result<()> {
info!("Pruning stale worktree references");
let result = Command::new("git")
.args(["worktree", "prune"])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to execute git worktree prune")?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!(stderr = %stderr, "git worktree prune failed (non-fatal)");
} else {
info!("git worktree prune completed");
}
Ok(())
}
async fn is_branch_merged(&self, branch: &str) -> Result<bool> {
let result = Command::new("git")
.args(["merge-base", "--is-ancestor", branch, "origin/main"])
.current_dir(&self.project_dir)
.output()
.await
.context("Failed to check if branch is merged")?;
Ok(result.status.success())
}
async fn has_uncommitted_changes(&self, worktree_path: &Path) -> bool {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree_path)
.output()
.await;
match output {
Ok(o) => !o.stdout.is_empty(),
Err(_) => false,
}
}
async fn write_context_files(&self, worktree_path: &Path, agent_type: AgentType) -> Result<()> {
use std::os::unix::fs::symlink;
let exomonad_dir = worktree_path.join(".exomonad");
fs::create_dir_all(&exomonad_dir).await?;
let rel_path = worktree_path.strip_prefix(&self.project_dir).map_err(|e| {
warn!(
worktree_path = %worktree_path.display(),
project_dir = %self.project_dir.display(),
error = %e,
"worktree_path is not under project_dir"
);
anyhow!(
"Internal configuration error: worktree_path {:?} is not under project_dir {:?}",
worktree_path,
self.project_dir
)
})?;
let depth = rel_path.components().count();
let rel_to_root = "../".repeat(depth + 1);
let rel_to_root = rel_to_root.trim_end_matches('/');
let config_content = format!(
r###"# Agent config (auto-generated)
default_role = "dev"
project_dir = "{}"
"###,
rel_to_root
);
fs::write(exomonad_dir.join("config.toml"), config_content).await?;
tracing::info!(
worktree = %worktree_path.display(),
rel_to_root = %rel_to_root,
"Wrote .exomonad/config.toml with default_role=dev"
);
let symlinks = [
("roles", format!("{}/.exomonad/roles", rel_to_root)),
("lib", format!("{}/.exomonad/lib", rel_to_root)),
];
for (name, target) in symlinks {
let link_path = exomonad_dir.join(name);
let target_path = Path::new(&target);
if link_path.exists() && !link_path.is_symlink() {
if link_path.is_dir() {
fs::remove_dir_all(&link_path).await.ok();
tracing::debug!(name = %name, "Removed existing directory for symlink");
} else if link_path.is_file() {
fs::remove_file(&link_path).await.ok();
tracing::debug!(name = %name, "Removed existing file for symlink");
}
}
if !link_path.exists() {
if let Err(e) = symlink(target_path, &link_path) {
tracing::warn!(
name = %name,
target = %target,
error = %e,
"Failed to create symlink"
);
} else {
tracing::debug!(name = %name, target = %target, "Created symlink");
}
}
}
let gitignore_content = "# Auto-generated for worktree\nresult\n";
fs::write(exomonad_dir.join(".gitignore"), gitignore_content).await?;
let sidecar_path = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "exomonad".to_string());
let mcp_content = format!(
r###"{{
"mcpServers": {{
"exomonad": {{
"command": "{}",
"args": ["mcp-stdio"]
}}
}}
}} "###,
sidecar_path
);
fs::write(worktree_path.join(".mcp.json"), mcp_content).await?;
match agent_type {
AgentType::Claude => {
let claude_dir = worktree_path.join(".claude");
fs::create_dir_all(&claude_dir).await?;
let settings_content = format!(
r###"{{
"enableAllProjectMcpServers": true,
"hooks": {{
"PreToolUse": [
{{
"hooks": [
{{
"type": "command",
"command": "{sidecar} hook pre-tool-use"
}}
]
}}
],
"SubagentStop": [
{{
"hooks": [
{{
"type": "command",
"command": "{sidecar} hook subagent-stop"
}}
]
}}
],
"SessionEnd": [
{{
"hooks": [
{{
"type": "command",
"command": "{sidecar} hook session-end"
}}
]
}}
]
}}
}} "###,
sidecar = sidecar_path
);
fs::write(claude_dir.join("settings.local.json"), settings_content).await?;
tracing::info!(
worktree = %worktree_path.display(),
"Wrote .claude/settings.local.json with PreToolUse, SubagentStop, SessionEnd hooks"
);
}
AgentType::Gemini => {
let gemini_dir = worktree_path.join(".gemini");
fs::create_dir_all(&gemini_dir).await?;
let settings_content = format!(
r###"{{
"mcpServers": {{
"exomonad": {{
"command": "{}",
"args": ["mcp-stdio"]
}}
}},
"hooks": {{
"AfterAgent": [
{{
"matcher": "AfterAgent",
"hooks": [
{{
"name": "stop-check",
"type": "command",
"command": "{} hook after-agent --runtime gemini",
"timeout": 30000
}}
]
}}
]
}}
}} "###,
sidecar_path, sidecar_path
);
fs::write(gemini_dir.join("settings.json"), settings_content).await?;
tracing::info!(
worktree = %worktree_path.display(),
"Wrote .gemini/settings.json with AfterAgent hook"
);
}
}
info!(worktree = %worktree_path.display(), "Context files written");
Ok(())
}
fn build_initial_prompt(
issue_id: &str,
title: &str,
body: &str,
labels: &[String],
branch: &str,
issue_url: &str,
) -> String {
let labels_str = if labels.is_empty() {
"None".to_string()
} else {
labels
.iter()
.map(|l| format!("`{}`", l))
.collect::<Vec<_>>()
.join(", ")
};
format!(
r###"# Issue #{issue_id}: {title}
**Branch:** `{branch}`
**Issue URL:** {issue_url}
**Labels:** {labels_str}
## Description
{body}"###,
issue_id = issue_id,
title = title,
branch = branch,
issue_url = issue_url,
labels_str = labels_str,
body = body,
)
}
fn escape_for_shell_command(s: &str) -> String {
let escaped = s.replace('\'', r"'\''");
format!("'{}'", escaped)
}
fn escape_for_kdl(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
}
fn slugify(title: &str) -> String {
title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
.chars()
.take(50)
.collect()
}
#[derive(Debug, PartialEq)]
struct ParsedWorktreeName<'a> {
issue_id: &'a str,
slug: &'a str,
agent_type: Option<AgentType>,
}
fn parse_worktree_name(name: &str) -> Option<ParsedWorktreeName<'_>> {
let rest = name.strip_prefix("gh-")?;
let (issue_id, rest) = rest.split_once('-')?;
let (slug, agent_suffix) = rest.rsplit_once('-')?;
let agent_type = match agent_suffix {
"claude" => Some(AgentType::Claude),
"gemini" => Some(AgentType::Gemini),
_ => None,
};
Some(ParsedWorktreeName {
issue_id,
slug,
agent_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Fix the Bug"), "fix-the-bug");
assert_eq!(slugify("Add new feature!"), "add-new-feature");
assert_eq!(slugify("CamelCase"), "camelcase");
}
#[test]
fn test_escape_for_shell_command_simple() {
assert_eq!(
AgentControlService::escape_for_shell_command("hello world"),
"'hello world'"
);
}
#[test]
fn test_escape_for_shell_command_with_quote() {
assert_eq!(
AgentControlService::escape_for_shell_command("user's issue"),
r"'user'\''s issue'"
);
}
#[test]
fn test_escape_for_shell_command_shell_chars() {
let result = AgentControlService::escape_for_shell_command("Test $VAR and `code`");
assert!(result.contains("$VAR"));
assert!(result.contains("`code`"));
assert_eq!(result, "'Test $VAR and `code`'");
}
#[test]
fn test_build_initial_prompt_format() {
let prompt = AgentControlService::build_initial_prompt(
"123",
"Fix the bug",
"Description",
&["bug".to_string(), "priority".to_string()],
"gh-123/fix",
"https://github.com/owner/repo/issues/123",
);
assert!(prompt.contains("# Issue #123: Fix the bug"));
assert!(prompt.contains("**Branch:** `gh-123/fix`"));
assert!(prompt.contains("Description"));
assert!(prompt.contains("https://github.com/owner/repo/issues/123"));
assert!(prompt.contains("**Labels:** `bug`, `priority`"));
assert!(!prompt.contains("When done, commit"));
}
#[test]
fn test_build_initial_prompt_no_labels() {
let prompt = AgentControlService::build_initial_prompt(
"123",
"Fix the bug",
"Description",
&[],
"gh-123/fix",
"https://github.com/owner/repo/issues/123",
);
assert!(prompt.contains("**Labels:** None"));
}
#[test]
fn test_agent_type_command() {
assert_eq!(AgentType::Claude.command(), "claude");
assert_eq!(AgentType::Gemini.command(), "gemini");
}
#[test]
fn test_agent_type_prompt_flag() {
assert_eq!(AgentType::Claude.prompt_flag(), "--prompt");
assert_eq!(AgentType::Gemini.prompt_flag(), "--prompt-interactive");
}
#[test]
fn test_agent_type_suffix() {
assert_eq!(AgentType::Claude.suffix(), "claude");
assert_eq!(AgentType::Gemini.suffix(), "gemini");
}
#[test]
fn test_agent_type_default() {
assert_eq!(AgentType::default(), AgentType::Gemini);
}
#[test]
fn test_agent_type_emoji() {
assert_eq!(AgentType::Claude.emoji(), "🤖");
assert_eq!(AgentType::Gemini.emoji(), "💎");
}
#[test]
fn test_agent_type_display_name() {
assert_eq!(
AgentType::Claude.display_name("473", "refactor-polish"),
"🤖 gh-473-refactor-polish"
);
assert_eq!(
AgentType::Gemini.display_name("123", "fix-bug"),
"💎 gh-123-fix-bug"
);
}
#[test]
fn test_agent_type_display_name_truncates_long_slug() {
let long_slug = "this-is-a-very-long-slug-that-should-be-truncated";
let display = AgentType::Claude.display_name("123", long_slug);
assert_eq!(display, "🤖 gh-123-this-is-a-very-long-");
}
#[test]
fn test_extract_issue_id_from_tab_name() {
let service = AgentControlService::new(
PathBuf::from("/tmp"),
None,
GitService::new(Arc::new(LocalExecutor)),
);
assert_eq!(
service.extract_issue_id_from_tab_name("🤖 gh-473-refactor-polish"),
Some("473".to_string())
);
assert_eq!(
service.extract_issue_id_from_tab_name("💎 gh-123-fix-bug"),
Some("123".to_string())
);
assert_eq!(
service.extract_issue_id_from_tab_name("🤖 473-refactor-polish"),
Some("473".to_string())
);
assert_eq!(service.extract_issue_id_from_tab_name("🤖"), None);
assert_eq!(service.extract_issue_id_from_tab_name("just-text"), None);
}
#[test]
fn test_agent_type_deserialization() {
use serde_json;
let claude: AgentType = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(claude, AgentType::Claude);
let gemini: AgentType = serde_json::from_str("\"gemini\"").unwrap();
assert_eq!(gemini, AgentType::Gemini);
let invalid = serde_json::from_str::<AgentType>("\"invalid\"");
assert!(invalid.is_err());
}
#[test]
fn test_parse_worktree_name_claude() {
let parsed = super::parse_worktree_name("gh-123-fix-bug-claude").unwrap();
assert_eq!(parsed.issue_id, "123");
assert_eq!(parsed.slug, "fix-bug");
assert_eq!(parsed.agent_type, Some(AgentType::Claude));
}
#[test]
fn test_parse_worktree_name_gemini() {
let parsed = super::parse_worktree_name("gh-456-add-feature-gemini").unwrap();
assert_eq!(parsed.issue_id, "456");
assert_eq!(parsed.slug, "add-feature");
assert_eq!(parsed.agent_type, Some(AgentType::Gemini));
}
#[test]
fn test_parse_worktree_name_slug_with_hyphens() {
let parsed = super::parse_worktree_name("gh-789-fix-the-big-bug-claude").unwrap();
assert_eq!(parsed.issue_id, "789");
assert_eq!(parsed.slug, "fix-the-big-bug");
assert_eq!(parsed.agent_type, Some(AgentType::Claude));
}
#[test]
fn test_parse_worktree_name_unknown_suffix() {
let parsed = super::parse_worktree_name("gh-123-test-unknown").unwrap();
assert_eq!(parsed.issue_id, "123");
assert_eq!(parsed.slug, "test");
assert_eq!(parsed.agent_type, None);
}
#[test]
fn test_parse_worktree_name_invalid_format() {
assert!(super::parse_worktree_name("123-test-claude").is_none());
assert!(super::parse_worktree_name("gh-nohyphens").is_none());
assert!(super::parse_worktree_name("gh-123").is_none());
}
}