2026-02-19 17:58:53 +00:00
|
|
|
use crate::config::ProjectConfig;
|
|
|
|
|
use crate::worktree::{self, WorktreeInfo};
|
2026-02-19 15:25:22 +00:00
|
|
|
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
2026-02-19 17:58:53 +00:00
|
|
|
use serde::Serialize;
|
2026-02-19 15:25:22 +00:00
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::io::{BufRead, BufReader};
|
2026-02-19 17:58:53 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
|
|
|
|
|
|
/// Events streamed from a running agent to SSE clients.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
|
|
|
pub enum AgentEvent {
|
|
|
|
|
/// Agent status changed.
|
|
|
|
|
Status { story_id: String, status: String },
|
|
|
|
|
/// Raw text output from the agent process.
|
|
|
|
|
Output { story_id: String, text: String },
|
|
|
|
|
/// Agent produced a JSON event from `--output-format stream-json`.
|
|
|
|
|
AgentJson { story_id: String, data: serde_json::Value },
|
|
|
|
|
/// Agent finished.
|
|
|
|
|
Done {
|
|
|
|
|
story_id: String,
|
|
|
|
|
session_id: Option<String>,
|
|
|
|
|
},
|
|
|
|
|
/// Agent errored.
|
|
|
|
|
Error { story_id: String, message: String },
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
2026-02-19 15:25:22 +00:00
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
pub enum AgentStatus {
|
2026-02-19 17:58:53 +00:00
|
|
|
Pending,
|
2026-02-19 15:25:22 +00:00
|
|
|
Running,
|
2026-02-19 17:58:53 +00:00
|
|
|
Completed,
|
|
|
|
|
Failed,
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
impl std::fmt::Display for AgentStatus {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Pending => write!(f, "pending"),
|
|
|
|
|
Self::Running => write!(f, "running"),
|
|
|
|
|
Self::Completed => write!(f, "completed"),
|
|
|
|
|
Self::Failed => write!(f, "failed"),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
#[derive(Serialize, Clone)]
|
|
|
|
|
pub struct AgentInfo {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub status: AgentStatus,
|
|
|
|
|
pub session_id: Option<String>,
|
|
|
|
|
pub worktree_path: Option<String>,
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
struct StoryAgent {
|
|
|
|
|
status: AgentStatus,
|
|
|
|
|
worktree_info: Option<WorktreeInfo>,
|
|
|
|
|
config: ProjectConfig,
|
|
|
|
|
session_id: Option<String>,
|
|
|
|
|
tx: broadcast::Sender<AgentEvent>,
|
|
|
|
|
task_handle: Option<tokio::task::JoinHandle<()>>,
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// Manages concurrent story agents, each in its own worktree.
|
|
|
|
|
pub struct AgentPool {
|
|
|
|
|
agents: Arc<Mutex<HashMap<String, StoryAgent>>>,
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AgentPool {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
2026-02-19 17:58:53 +00:00
|
|
|
agents: Arc::new(Mutex::new(HashMap::new())),
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// Start an agent for a story: load config, create worktree, spawn agent.
|
|
|
|
|
pub async fn start_agent(
|
|
|
|
|
&self,
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
story_id: &str,
|
|
|
|
|
) -> Result<AgentInfo, String> {
|
|
|
|
|
// Check not already running
|
|
|
|
|
{
|
|
|
|
|
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(agent) = agents.get(story_id)
|
|
|
|
|
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"Agent for story '{story_id}' is already {}",
|
|
|
|
|
agent.status
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 15:25:22 +00:00
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
let config = ProjectConfig::load(project_root)?;
|
|
|
|
|
let (tx, _) = broadcast::channel::<AgentEvent>(256);
|
|
|
|
|
|
|
|
|
|
// Register as pending
|
|
|
|
|
{
|
|
|
|
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
agents.insert(
|
|
|
|
|
story_id.to_string(),
|
|
|
|
|
StoryAgent {
|
|
|
|
|
status: AgentStatus::Pending,
|
|
|
|
|
worktree_info: None,
|
|
|
|
|
config: config.clone(),
|
|
|
|
|
session_id: None,
|
|
|
|
|
tx: tx.clone(),
|
|
|
|
|
task_handle: None,
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
let _ = tx.send(AgentEvent::Status {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
status: "pending".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create worktree
|
|
|
|
|
let wt_info = worktree::create_worktree(project_root, story_id, &config).await?;
|
2026-02-19 15:25:22 +00:00
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
// Update with worktree info
|
|
|
|
|
{
|
|
|
|
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(agent) = agents.get_mut(story_id) {
|
|
|
|
|
agent.worktree_info = Some(wt_info.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn the agent process
|
|
|
|
|
let wt_path_str = wt_info.path.to_string_lossy().to_string();
|
|
|
|
|
let rendered = config.render_agent_args(&wt_path_str, story_id);
|
|
|
|
|
|
|
|
|
|
let (command, args, prompt) = rendered.ok_or_else(|| {
|
|
|
|
|
"No [agent] section in config — cannot spawn agent".to_string()
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let sid = story_id.to_string();
|
|
|
|
|
let tx_clone = tx.clone();
|
|
|
|
|
let agents_ref = self.agents.clone();
|
|
|
|
|
let cwd = wt_path_str.clone();
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(async move {
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Status {
|
|
|
|
|
story_id: sid.clone(),
|
|
|
|
|
status: "running".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match run_agent_pty_streaming(&sid, &command, &args, &prompt, &cwd, &tx_clone).await {
|
|
|
|
|
Ok(session_id) => {
|
|
|
|
|
// Mark completed in the pool
|
|
|
|
|
if let Ok(mut agents) = agents_ref.lock()
|
|
|
|
|
&& let Some(agent) = agents.get_mut(&sid) {
|
|
|
|
|
agent.status = AgentStatus::Completed;
|
|
|
|
|
agent.session_id = session_id.clone();
|
|
|
|
|
}
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Done {
|
|
|
|
|
story_id: sid.clone(),
|
|
|
|
|
session_id,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Mark failed in the pool
|
|
|
|
|
if let Ok(mut agents) = agents_ref.lock()
|
|
|
|
|
&& let Some(agent) = agents.get_mut(&sid) {
|
|
|
|
|
agent.status = AgentStatus::Failed;
|
|
|
|
|
}
|
|
|
|
|
let _ = tx_clone.send(AgentEvent::Error {
|
|
|
|
|
story_id: sid.clone(),
|
|
|
|
|
message: e,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update status to running with task handle
|
|
|
|
|
{
|
|
|
|
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(agent) = agents.get_mut(story_id) {
|
|
|
|
|
agent.status = AgentStatus::Running;
|
|
|
|
|
agent.task_handle = Some(handle);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(AgentInfo {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
status: AgentStatus::Running,
|
2026-02-19 15:25:22 +00:00
|
|
|
session_id: None,
|
2026-02-19 17:58:53 +00:00
|
|
|
worktree_path: Some(wt_path_str),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stop a running agent and clean up its worktree.
|
|
|
|
|
pub async fn stop_agent(&self, project_root: &Path, story_id: &str) -> Result<(), String> {
|
|
|
|
|
let (worktree_info, config, task_handle, tx) = {
|
|
|
|
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
let agent = agents
|
|
|
|
|
.get_mut(story_id)
|
|
|
|
|
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
|
|
|
|
|
|
|
|
|
let wt = agent.worktree_info.clone();
|
|
|
|
|
let cfg = agent.config.clone();
|
|
|
|
|
let handle = agent.task_handle.take();
|
|
|
|
|
let tx = agent.tx.clone();
|
|
|
|
|
agent.status = AgentStatus::Failed;
|
|
|
|
|
(wt, cfg, handle, tx)
|
2026-02-19 15:25:22 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
// Abort the task
|
|
|
|
|
if let Some(handle) = task_handle {
|
|
|
|
|
handle.abort();
|
|
|
|
|
let _ = handle.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove worktree
|
|
|
|
|
if let Some(ref wt) = worktree_info
|
|
|
|
|
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await {
|
|
|
|
|
eprintln!("[agents] Worktree cleanup warning for {story_id}: {e}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = tx.send(AgentEvent::Status {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
status: "stopped".to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Remove from map
|
|
|
|
|
{
|
|
|
|
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
agents.remove(story_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// List all agents with their status.
|
2026-02-19 15:25:22 +00:00
|
|
|
pub fn list_agents(&self) -> Result<Vec<AgentInfo>, String> {
|
|
|
|
|
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
Ok(agents
|
|
|
|
|
.iter()
|
2026-02-19 17:58:53 +00:00
|
|
|
.map(|(story_id, agent)| AgentInfo {
|
|
|
|
|
story_id: story_id.clone(),
|
|
|
|
|
status: agent.status.clone(),
|
|
|
|
|
session_id: agent.session_id.clone(),
|
|
|
|
|
worktree_path: agent
|
|
|
|
|
.worktree_info
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|wt| wt.path.to_string_lossy().to_string()),
|
2026-02-19 15:25:22 +00:00
|
|
|
})
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// Subscribe to events for a story agent.
|
|
|
|
|
pub fn subscribe(&self, story_id: &str) -> Result<broadcast::Receiver<AgentEvent>, String> {
|
|
|
|
|
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
let agent = agents
|
|
|
|
|
.get(story_id)
|
|
|
|
|
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
|
|
|
|
Ok(agent.tx.subscribe())
|
|
|
|
|
}
|
2026-02-19 15:25:22 +00:00
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// Get project root helper.
|
|
|
|
|
pub fn get_project_root(
|
|
|
|
|
&self,
|
|
|
|
|
state: &crate::state::SessionState,
|
|
|
|
|
) -> Result<PathBuf, String> {
|
|
|
|
|
state.get_project_root()
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
|
|
|
|
|
async fn run_agent_pty_streaming(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
command: &str,
|
|
|
|
|
args: &[String],
|
|
|
|
|
prompt: &str,
|
2026-02-19 15:25:22 +00:00
|
|
|
cwd: &str,
|
2026-02-19 17:58:53 +00:00
|
|
|
tx: &broadcast::Sender<AgentEvent>,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
|
|
|
|
let sid = story_id.to_string();
|
|
|
|
|
let cmd = command.to_string();
|
|
|
|
|
let args = args.to_vec();
|
|
|
|
|
let prompt = prompt.to_string();
|
|
|
|
|
let cwd = cwd.to_string();
|
|
|
|
|
let tx = tx.clone();
|
|
|
|
|
|
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
|
|
|
run_agent_pty_blocking(&sid, &cmd, &args, &prompt, &cwd, &tx)
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Agent task panicked: {e}"))?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_agent_pty_blocking(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
command: &str,
|
|
|
|
|
args: &[String],
|
|
|
|
|
prompt: &str,
|
|
|
|
|
cwd: &str,
|
|
|
|
|
tx: &broadcast::Sender<AgentEvent>,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
2026-02-19 15:25:22 +00:00
|
|
|
let pty_system = native_pty_system();
|
|
|
|
|
|
|
|
|
|
let pair = pty_system
|
|
|
|
|
.openpty(PtySize {
|
|
|
|
|
rows: 50,
|
|
|
|
|
cols: 200,
|
|
|
|
|
pixel_width: 0,
|
|
|
|
|
pixel_height: 0,
|
|
|
|
|
})
|
|
|
|
|
.map_err(|e| format!("Failed to open PTY: {e}"))?;
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
let mut cmd = CommandBuilder::new(command);
|
|
|
|
|
|
|
|
|
|
// -p <prompt> must come first
|
2026-02-19 15:25:22 +00:00
|
|
|
cmd.arg("-p");
|
2026-02-19 17:58:53 +00:00
|
|
|
cmd.arg(prompt);
|
|
|
|
|
|
|
|
|
|
// Add configured args (e.g., --directory /path/to/worktree)
|
|
|
|
|
for arg in args {
|
|
|
|
|
cmd.arg(arg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:25:22 +00:00
|
|
|
cmd.arg("--output-format");
|
|
|
|
|
cmd.arg("stream-json");
|
|
|
|
|
cmd.arg("--verbose");
|
|
|
|
|
|
2026-02-19 15:56:05 +00:00
|
|
|
// Supervised agents don't need interactive permission prompts
|
|
|
|
|
cmd.arg("--permission-mode");
|
|
|
|
|
cmd.arg("bypassPermissions");
|
|
|
|
|
|
2026-02-19 15:25:22 +00:00
|
|
|
cmd.cwd(cwd);
|
|
|
|
|
cmd.env("NO_COLOR", "1");
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
eprintln!("[agent:{story_id}] Spawning {command} in {cwd} with args: {args:?}");
|
2026-02-19 15:25:22 +00:00
|
|
|
|
|
|
|
|
let mut child = pair
|
|
|
|
|
.slave
|
|
|
|
|
.spawn_command(cmd)
|
2026-02-19 17:58:53 +00:00
|
|
|
.map_err(|e| format!("Failed to spawn agent for {story_id}: {e}"))?;
|
2026-02-19 15:25:22 +00:00
|
|
|
|
|
|
|
|
drop(pair.slave);
|
|
|
|
|
|
|
|
|
|
let reader = pair
|
|
|
|
|
.master
|
|
|
|
|
.try_clone_reader()
|
|
|
|
|
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
|
|
|
|
|
|
|
|
|
drop(pair.master);
|
|
|
|
|
|
|
|
|
|
let buf_reader = BufReader::new(reader);
|
2026-02-19 17:58:53 +00:00
|
|
|
let mut session_id: Option<String> = None;
|
2026-02-19 15:25:22 +00:00
|
|
|
|
|
|
|
|
for line in buf_reader.lines() {
|
|
|
|
|
let line = match line {
|
|
|
|
|
Ok(l) => l,
|
|
|
|
|
Err(_) => break,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
// Try to parse as JSON
|
2026-02-19 15:25:22 +00:00
|
|
|
let json: serde_json::Value = match serde_json::from_str(trimmed) {
|
|
|
|
|
Ok(j) => j,
|
2026-02-19 17:58:53 +00:00
|
|
|
Err(_) => {
|
|
|
|
|
// Non-JSON output (terminal escapes etc.) — send as raw output
|
|
|
|
|
let _ = tx.send(AgentEvent::Output {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
text: trimmed.to_string(),
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-02-19 15:25:22 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
|
|
|
|
|
|
|
|
match event_type {
|
|
|
|
|
"system" => {
|
2026-02-19 17:58:53 +00:00
|
|
|
session_id = json
|
2026-02-19 15:25:22 +00:00
|
|
|
.get("session_id")
|
|
|
|
|
.and_then(|s| s.as_str())
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
}
|
|
|
|
|
"assistant" => {
|
2026-02-19 17:58:53 +00:00
|
|
|
if let Some(message) = json.get("message")
|
|
|
|
|
&& let Some(content) = message.get("content").and_then(|c| c.as_array()) {
|
2026-02-19 15:25:22 +00:00
|
|
|
for block in content {
|
|
|
|
|
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
2026-02-19 17:58:53 +00:00
|
|
|
let _ = tx.send(AgentEvent::Output {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
text: text.to_string(),
|
|
|
|
|
});
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
2026-02-19 17:58:53 +00:00
|
|
|
|
|
|
|
|
// Forward all JSON events
|
|
|
|
|
let _ = tx.send(AgentEvent::AgentJson {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
data: json,
|
|
|
|
|
});
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
|
|
|
|
|
eprintln!(
|
2026-02-19 17:58:53 +00:00
|
|
|
"[agent:{story_id}] Done. Session: {:?}",
|
|
|
|
|
session_id
|
2026-02-19 15:25:22 +00:00
|
|
|
);
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
Ok(session_id)
|
2026-02-19 15:25:22 +00:00
|
|
|
}
|