mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-13 04:06:58 +08:00
450 lines
13 KiB
Rust
450 lines
13 KiB
Rust
mod comms;
|
|
mod config;
|
|
mod observability;
|
|
mod session;
|
|
mod tui;
|
|
mod worktree;
|
|
|
|
use anyhow::Result;
|
|
use clap::Parser;
|
|
use std::path::PathBuf;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "ecc", version, about = "ECC 2.0 — Agentic IDE control plane")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(clap::Subcommand, Debug)]
|
|
enum Commands {
|
|
/// Launch the TUI dashboard
|
|
Dashboard,
|
|
/// Start a new agent session
|
|
Start {
|
|
/// Task description for the agent
|
|
#[arg(short, long)]
|
|
task: String,
|
|
/// Agent type (claude, codex, custom)
|
|
#[arg(short, long, default_value = "claude")]
|
|
agent: String,
|
|
/// Create a dedicated worktree for this session
|
|
#[arg(short, long)]
|
|
worktree: bool,
|
|
/// Source session to delegate from
|
|
#[arg(long)]
|
|
from_session: Option<String>,
|
|
},
|
|
/// Delegate a new session from an existing one
|
|
Delegate {
|
|
/// Source session ID or alias
|
|
from_session: String,
|
|
/// Task description for the delegated session
|
|
#[arg(short, long)]
|
|
task: Option<String>,
|
|
/// Agent type (claude, codex, custom)
|
|
#[arg(short, long, default_value = "claude")]
|
|
agent: String,
|
|
/// Create a dedicated worktree for the delegated session
|
|
#[arg(short, long, default_value_t = true)]
|
|
worktree: bool,
|
|
},
|
|
/// List active sessions
|
|
Sessions,
|
|
/// Show session details
|
|
Status {
|
|
/// Session ID or alias
|
|
session_id: Option<String>,
|
|
},
|
|
/// Stop a running session
|
|
Stop {
|
|
/// Session ID or alias
|
|
session_id: String,
|
|
},
|
|
/// Resume a failed or stopped session
|
|
Resume {
|
|
/// Session ID or alias
|
|
session_id: String,
|
|
},
|
|
/// Send or inspect inter-session messages
|
|
Messages {
|
|
#[command(subcommand)]
|
|
command: MessageCommands,
|
|
},
|
|
/// Run as background daemon
|
|
Daemon,
|
|
#[command(hide = true)]
|
|
RunSession {
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
task: String,
|
|
#[arg(long)]
|
|
agent: String,
|
|
#[arg(long)]
|
|
cwd: PathBuf,
|
|
},
|
|
}
|
|
|
|
#[derive(clap::Subcommand, Debug)]
|
|
enum MessageCommands {
|
|
/// Send a structured message between sessions
|
|
Send {
|
|
#[arg(long)]
|
|
from: String,
|
|
#[arg(long)]
|
|
to: String,
|
|
#[arg(long, value_enum)]
|
|
kind: MessageKindArg,
|
|
#[arg(long)]
|
|
text: String,
|
|
#[arg(long)]
|
|
context: Option<String>,
|
|
#[arg(long)]
|
|
file: Vec<String>,
|
|
},
|
|
/// Show recent messages for a session
|
|
Inbox {
|
|
session_id: String,
|
|
#[arg(long, default_value_t = 10)]
|
|
limit: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(clap::ValueEnum, Clone, Debug)]
|
|
enum MessageKindArg {
|
|
Handoff,
|
|
Query,
|
|
Response,
|
|
Completed,
|
|
Conflict,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
let cfg = config::Config::load()?;
|
|
let db = session::store::StateStore::open(&cfg.db_path)?;
|
|
|
|
match cli.command {
|
|
Some(Commands::Dashboard) | None => {
|
|
tui::app::run(db, cfg).await?;
|
|
}
|
|
Some(Commands::Start {
|
|
task,
|
|
agent,
|
|
worktree: use_worktree,
|
|
from_session,
|
|
}) => {
|
|
let session_id =
|
|
session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?;
|
|
if let Some(from_session) = from_session {
|
|
let from_id = resolve_session_id(&db, &from_session)?;
|
|
send_handoff_message(&db, &from_id, &session_id)?;
|
|
}
|
|
println!("Session started: {session_id}");
|
|
}
|
|
Some(Commands::Delegate {
|
|
from_session,
|
|
task,
|
|
agent,
|
|
worktree: use_worktree,
|
|
}) => {
|
|
let from_id = resolve_session_id(&db, &from_session)?;
|
|
let source = db
|
|
.get_session(&from_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?;
|
|
let task = task.unwrap_or_else(|| {
|
|
format!(
|
|
"Follow up on {}: {}",
|
|
short_session(&source.id),
|
|
source.task
|
|
)
|
|
});
|
|
|
|
let session_id =
|
|
session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?;
|
|
send_handoff_message(&db, &source.id, &session_id)?;
|
|
println!(
|
|
"Delegated session started: {} <- {}",
|
|
session_id,
|
|
short_session(&source.id)
|
|
);
|
|
}
|
|
Some(Commands::Sessions) => {
|
|
let sessions = session::manager::list_sessions(&db)?;
|
|
for s in sessions {
|
|
println!("{} [{}] {}", s.id, s.state, s.task);
|
|
}
|
|
}
|
|
Some(Commands::Status { session_id }) => {
|
|
let id = session_id.unwrap_or_else(|| "latest".to_string());
|
|
let status = session::manager::get_status(&db, &id)?;
|
|
println!("{status}");
|
|
}
|
|
Some(Commands::Stop { session_id }) => {
|
|
session::manager::stop_session(&db, &session_id).await?;
|
|
println!("Session stopped: {session_id}");
|
|
}
|
|
Some(Commands::Resume { session_id }) => {
|
|
let resumed_id = session::manager::resume_session(&db, &cfg, &session_id).await?;
|
|
println!("Session resumed: {resumed_id}");
|
|
}
|
|
Some(Commands::Messages { command }) => match command {
|
|
MessageCommands::Send {
|
|
from,
|
|
to,
|
|
kind,
|
|
text,
|
|
context,
|
|
file,
|
|
} => {
|
|
let from = resolve_session_id(&db, &from)?;
|
|
let to = resolve_session_id(&db, &to)?;
|
|
let message = build_message(kind, text, context, file)?;
|
|
comms::send(&db, &from, &to, &message)?;
|
|
println!("Message sent: {} -> {}", short_session(&from), short_session(&to));
|
|
}
|
|
MessageCommands::Inbox { session_id, limit } => {
|
|
let session_id = resolve_session_id(&db, &session_id)?;
|
|
let messages = db.list_messages_for_session(&session_id, limit)?;
|
|
let unread_before = db
|
|
.unread_message_counts()?
|
|
.get(&session_id)
|
|
.copied()
|
|
.unwrap_or(0);
|
|
if unread_before > 0 {
|
|
let _ = db.mark_messages_read(&session_id)?;
|
|
}
|
|
|
|
if messages.is_empty() {
|
|
println!("No messages for {}", short_session(&session_id));
|
|
} else {
|
|
println!("Messages for {}", short_session(&session_id));
|
|
for message in messages {
|
|
println!(
|
|
"{} {} -> {} | {}",
|
|
message.timestamp.format("%H:%M:%S"),
|
|
short_session(&message.from_session),
|
|
short_session(&message.to_session),
|
|
comms::preview(&message.msg_type, &message.content)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Some(Commands::Daemon) => {
|
|
println!("Starting ECC daemon...");
|
|
session::daemon::run(db, cfg).await?;
|
|
}
|
|
Some(Commands::RunSession {
|
|
session_id,
|
|
task,
|
|
agent,
|
|
cwd,
|
|
}) => {
|
|
session::manager::run_session(&cfg, &session_id, &task, &agent, &cwd).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_session_id(db: &session::store::StateStore, value: &str) -> Result<String> {
|
|
if value == "latest" {
|
|
return db
|
|
.get_latest_session()?
|
|
.map(|session| session.id)
|
|
.ok_or_else(|| anyhow::anyhow!("No sessions found"));
|
|
}
|
|
|
|
db.get_session(value)?
|
|
.map(|session| session.id)
|
|
.ok_or_else(|| anyhow::anyhow!("Session not found: {value}"))
|
|
}
|
|
|
|
fn build_message(
|
|
kind: MessageKindArg,
|
|
text: String,
|
|
context: Option<String>,
|
|
files: Vec<String>,
|
|
) -> Result<comms::MessageType> {
|
|
Ok(match kind {
|
|
MessageKindArg::Handoff => comms::MessageType::TaskHandoff {
|
|
task: text,
|
|
context: context.unwrap_or_default(),
|
|
},
|
|
MessageKindArg::Query => comms::MessageType::Query { question: text },
|
|
MessageKindArg::Response => comms::MessageType::Response { answer: text },
|
|
MessageKindArg::Completed => comms::MessageType::Completed {
|
|
summary: text,
|
|
files_changed: files,
|
|
},
|
|
MessageKindArg::Conflict => {
|
|
let file = files
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow::anyhow!("Conflict messages require at least one --file"))?;
|
|
comms::MessageType::Conflict {
|
|
file,
|
|
description: context.unwrap_or(text),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn short_session(session_id: &str) -> String {
|
|
session_id.chars().take(8).collect()
|
|
}
|
|
|
|
fn send_handoff_message(
|
|
db: &session::store::StateStore,
|
|
from_id: &str,
|
|
to_id: &str,
|
|
) -> Result<()> {
|
|
let from_session = db
|
|
.get_session(from_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?;
|
|
let context = format!(
|
|
"Delegated from {} [{}] | cwd {}{}",
|
|
short_session(&from_session.id),
|
|
from_session.agent_type,
|
|
from_session.working_dir.display(),
|
|
from_session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| format!(
|
|
" | worktree {} ({})",
|
|
worktree.branch,
|
|
worktree.path.display()
|
|
))
|
|
.unwrap_or_default()
|
|
);
|
|
|
|
comms::send(
|
|
db,
|
|
&from_session.id,
|
|
to_id,
|
|
&comms::MessageType::TaskHandoff {
|
|
task: from_session.task,
|
|
context,
|
|
},
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cli_parses_resume_command() {
|
|
let cli = Cli::try_parse_from(["ecc", "resume", "deadbeef"])
|
|
.expect("resume subcommand should parse");
|
|
|
|
match cli.command {
|
|
Some(Commands::Resume { session_id }) => assert_eq!(session_id, "deadbeef"),
|
|
_ => panic!("expected resume subcommand"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_parses_messages_send_command() {
|
|
let cli = Cli::try_parse_from([
|
|
"ecc",
|
|
"messages",
|
|
"send",
|
|
"--from",
|
|
"planner",
|
|
"--to",
|
|
"worker",
|
|
"--kind",
|
|
"query",
|
|
"--text",
|
|
"Need context",
|
|
])
|
|
.expect("messages send should parse");
|
|
|
|
match cli.command {
|
|
Some(Commands::Messages {
|
|
command:
|
|
MessageCommands::Send {
|
|
from,
|
|
to,
|
|
kind,
|
|
text,
|
|
..
|
|
},
|
|
}) => {
|
|
assert_eq!(from, "planner");
|
|
assert_eq!(to, "worker");
|
|
assert!(matches!(kind, MessageKindArg::Query));
|
|
assert_eq!(text, "Need context");
|
|
}
|
|
_ => panic!("expected messages send subcommand"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_parses_start_with_handoff_source() {
|
|
let cli = Cli::try_parse_from([
|
|
"ecc",
|
|
"start",
|
|
"--task",
|
|
"Follow up",
|
|
"--agent",
|
|
"claude",
|
|
"--from-session",
|
|
"planner",
|
|
])
|
|
.expect("start with handoff source should parse");
|
|
|
|
match cli.command {
|
|
Some(Commands::Start {
|
|
from_session,
|
|
task,
|
|
agent,
|
|
..
|
|
}) => {
|
|
assert_eq!(task, "Follow up");
|
|
assert_eq!(agent, "claude");
|
|
assert_eq!(from_session.as_deref(), Some("planner"));
|
|
}
|
|
_ => panic!("expected start subcommand"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_parses_delegate_command() {
|
|
let cli = Cli::try_parse_from([
|
|
"ecc",
|
|
"delegate",
|
|
"planner",
|
|
"--task",
|
|
"Review auth changes",
|
|
"--agent",
|
|
"codex",
|
|
])
|
|
.expect("delegate should parse");
|
|
|
|
match cli.command {
|
|
Some(Commands::Delegate {
|
|
from_session,
|
|
task,
|
|
agent,
|
|
..
|
|
}) => {
|
|
assert_eq!(from_session, "planner");
|
|
assert_eq!(task.as_deref(), Some("Review auth changes"));
|
|
assert_eq!(agent, "codex");
|
|
}
|
|
_ => panic!("expected delegate subcommand"),
|
|
}
|
|
}
|
|
}
|