feat: Claude Code Router (CCR) executor (#196)

Implement Claude Code Router exector
This commit is contained in:
Solomon
2025-07-16 10:08:16 +01:00
committed by GitHub
parent b5303b728e
commit c033963fd4
7 changed files with 252 additions and 53 deletions

View File

@@ -10,7 +10,8 @@ export const EXECUTOR_TYPES: string[] = [
"claude",
"amp",
"gemini",
"charmopencode"
"charmopencode",
"claude-code-router"
];
export const EDITOR_TYPES: EditorType[] = [
@@ -27,7 +28,8 @@ export const EXECUTOR_LABELS: Record<string, string> = {
"claude": "Claude",
"amp": "Amp",
"gemini": "Gemini",
"charmopencode": "Charm Opencode"
"charmopencode": "Charm Opencode",
"claude-code-router": "Claude Code Router"
};
export const EDITOR_LABELS: Record<string, string> = {

View File

@@ -7,7 +7,7 @@ use ts_rs::TS;
use uuid::Uuid;
use crate::executors::{
AmpExecutor, CharmOpencodeExecutor, ClaudeExecutor, EchoExecutor, GeminiExecutor,
AmpExecutor, CCRExecutor, CharmOpencodeExecutor, ClaudeExecutor, EchoExecutor, GeminiExecutor,
SetupScriptExecutor,
};
@@ -345,7 +345,12 @@ pub enum ExecutorConfig {
Claude,
Amp,
Gemini,
SetupScript { script: String },
SetupScript {
script: String,
},
#[serde(rename = "claude-code-router")]
#[ts(rename = "claude-code-router")]
ClaudeCodeRouter,
CharmOpencode,
// Future executors can be added here
// Shell { command: String },
@@ -370,6 +375,7 @@ impl FromStr for ExecutorConfig {
"amp" => Ok(ExecutorConfig::Amp),
"gemini" => Ok(ExecutorConfig::Gemini),
"charmopencode" => Ok(ExecutorConfig::CharmOpencode),
"claude-code-router" => Ok(ExecutorConfig::ClaudeCodeRouter),
"setup_script" => Ok(ExecutorConfig::SetupScript {
script: "setup script".to_string(),
}),
@@ -382,9 +388,10 @@ impl ExecutorConfig {
pub fn create_executor(&self) -> Box<dyn Executor> {
match self {
ExecutorConfig::Echo => Box::new(EchoExecutor),
ExecutorConfig::Claude => Box::new(ClaudeExecutor),
ExecutorConfig::Claude => Box::new(ClaudeExecutor::new()),
ExecutorConfig::Amp => Box::new(AmpExecutor),
ExecutorConfig::Gemini => Box::new(GeminiExecutor),
ExecutorConfig::ClaudeCodeRouter => Box::new(CCRExecutor::new()),
ExecutorConfig::CharmOpencode => Box::new(CharmOpencodeExecutor),
ExecutorConfig::SetupScript { script } => {
Box::new(SetupScriptExecutor::new(script.clone()))
@@ -398,7 +405,9 @@ impl ExecutorConfig {
ExecutorConfig::CharmOpencode => {
dirs::home_dir().map(|home| home.join(".opencode.json"))
}
ExecutorConfig::Claude => dirs::home_dir().map(|home| home.join(".claude.json")),
ExecutorConfig::Claude | ExecutorConfig::ClaudeCodeRouter => {
dirs::home_dir().map(|home| home.join(".claude.json"))
}
ExecutorConfig::Amp => {
dirs::config_dir().map(|config| config.join("amp").join("settings.json"))
}
@@ -417,6 +426,7 @@ impl ExecutorConfig {
ExecutorConfig::Claude => Some(vec!["mcpServers"]),
ExecutorConfig::Amp => Some(vec!["amp", "mcpServers"]), // Nested path for Amp
ExecutorConfig::Gemini => Some(vec!["mcpServers"]),
ExecutorConfig::ClaudeCodeRouter => Some(vec!["mcpServers"]),
ExecutorConfig::SetupScript { .. } => None, // Setup scripts don't support MCP
}
}
@@ -437,6 +447,7 @@ impl ExecutorConfig {
ExecutorConfig::Claude => "Claude",
ExecutorConfig::Amp => "Amp",
ExecutorConfig::Gemini => "Gemini",
ExecutorConfig::ClaudeCodeRouter => "Claude Code Router",
ExecutorConfig::SetupScript { .. } => "Setup Script",
}
}
@@ -450,6 +461,7 @@ impl std::fmt::Display for ExecutorConfig {
ExecutorConfig::Amp => "amp",
ExecutorConfig::Gemini => "gemini",
ExecutorConfig::CharmOpencode => "charmopencode",
ExecutorConfig::ClaudeCodeRouter => "claude-code-router",
ExecutorConfig::SetupScript { .. } => "setup_script",
};
write!(f, "{}", s)
@@ -905,7 +917,7 @@ mod tests {
#[test]
fn test_claude_log_normalization() {
let claude_executor = ClaudeExecutor;
let claude_executor = ClaudeExecutor::new();
let claude_logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp/mission-control-worktree-8ff34214-7bb4-4a5a-9f47-bfdf79e20368","session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9","tools":["Task","Bash","Glob","Grep","LS","exit_plan_mode","Read","Edit","MultiEdit","Write","NotebookRead","NotebookEdit","WebFetch","TodoRead","TodoWrite","WebSearch"],"mcp_servers":[],"model":"claude-sonnet-4-20250514","permissionMode":"bypassPermissions","apiKeySource":"none"}
{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll help you list your projects using vibe-kanban. Let me first explore the codebase to understand how vibe-kanban works and find your projects."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}
{"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01Br3TvXdmW6RPGpB5NihTHh","name":"Task","input":{"description":"Find vibe-kanban projects","prompt":"I need to find and list projects using vibe-kanban."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}"#;

View File

@@ -0,0 +1,115 @@
use async_trait::async_trait;
use command_group::AsyncGroupChild;
use uuid::Uuid;
use crate::{
executor::{Executor, ExecutorError, NormalizedConversation},
executors::{ClaudeExecutor, ClaudeFollowupExecutor},
};
/// An executor that uses Claude Code Router (CCR) to process tasks
/// This is a thin wrapper around ClaudeExecutor that uses Claude Code Router instead of Claude CLI
pub struct CCRExecutor(ClaudeExecutor);
impl Default for CCRExecutor {
fn default() -> Self {
Self::new()
}
}
impl CCRExecutor {
pub fn new() -> Self {
Self(ClaudeExecutor::with_command(
"claude-code-router".to_string(),
"npx -y @musistudio/claude-code-router code -p --dangerously-skip-permissions --verbose --output-format=stream-json".to_string(),
))
}
}
#[async_trait]
impl Executor for CCRExecutor {
async fn spawn(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
worktree_path: &str,
) -> Result<AsyncGroupChild, ExecutorError> {
self.0.spawn(pool, task_id, worktree_path).await
}
fn normalize_logs(
&self,
logs: &str,
worktree_path: &str,
) -> Result<NormalizedConversation, String> {
let filtered_logs = filter_ccr_service_messages(logs);
let mut result = self.0.normalize_logs(&filtered_logs, worktree_path)?;
result.executor_type = "claude-code-router".to_string();
Ok(result)
}
}
/// Filter out CCR service messages that appear in stdout but shouldn't be shown to users
/// These are informational messages from the CCR wrapper itself
fn filter_ccr_service_messages(logs: &str) -> String {
logs.lines()
.filter(|line| {
let trimmed = line.trim();
// Filter out known CCR service messages
if trimmed.eq("Service not running, starting service...")
|| trimmed.eq("claude code router service has been successfully stopped.")
{
return false;
}
// Filter out system init JSON that contains misleading model information
// CCR delegates to different models, so the init model info is incorrect
if trimmed.starts_with(r#"{"type":"system","subtype":"init""#)
&& trimmed.contains(r#""model":"#)
{
return false;
}
true
})
.collect::<Vec<&str>>()
.join("\n")
}
/// Claude Code Router followup executor - forwards to ClaudeFollowupExecutor with Claude Code Router command
pub struct CCRFollowupExecutor(ClaudeFollowupExecutor);
impl CCRFollowupExecutor {
pub fn new(session_id: String, prompt: String) -> Self {
Self(ClaudeFollowupExecutor::with_command(
session_id,
prompt,
"claude-code-router".to_string(),
"npx -y @musistudio/claude-code-router code -p --dangerously-skip-permissions --verbose --output-format=stream-json".to_string(),
))
}
}
#[async_trait]
impl Executor for CCRFollowupExecutor {
async fn spawn(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
worktree_path: &str,
) -> Result<AsyncGroupChild, ExecutorError> {
self.0.spawn(pool, task_id, worktree_path).await
}
fn normalize_logs(
&self,
logs: &str,
worktree_path: &str,
) -> Result<NormalizedConversation, String> {
let filtered_logs = filter_ccr_service_messages(logs);
let mut result = self.0.normalize_logs(&filtered_logs, worktree_path)?;
result.executor_type = "claude-code-router".to_string();
Ok(result)
}
}

View File

@@ -15,12 +15,68 @@ use crate::{
};
/// An executor that uses Claude CLI to process tasks
pub struct ClaudeExecutor;
pub struct ClaudeExecutor {
executor_type: String,
command: String,
}
impl Default for ClaudeExecutor {
fn default() -> Self {
Self::new()
}
}
impl ClaudeExecutor {
/// Create a new ClaudeExecutor with default settings
pub fn new() -> Self {
Self {
executor_type: "Claude".to_string(),
command: "npx -y @anthropic-ai/claude-code@latest -p --dangerously-skip-permissions --verbose --output-format=stream-json".to_string(),
}
}
/// Create a new ClaudeExecutor with custom settings
pub fn with_command(executor_type: String, command: String) -> Self {
Self {
executor_type,
command,
}
}
}
/// An executor that resumes a Claude session
pub struct ClaudeFollowupExecutor {
pub session_id: String,
pub prompt: String,
executor_type: String,
command_base: String,
}
impl ClaudeFollowupExecutor {
/// Create a new ClaudeFollowupExecutor with default settings
pub fn new(session_id: String, prompt: String) -> Self {
Self {
session_id,
prompt,
executor_type: "Claude".to_string(),
command_base: "npx -y @anthropic-ai/claude-code@latest -p --dangerously-skip-permissions --verbose --output-format=stream-json".to_string(),
}
}
/// Create a new ClaudeFollowupExecutor with custom settings
pub fn with_command(
session_id: String,
prompt: String,
executor_type: String,
command_base: String,
) -> Self {
Self {
session_id,
prompt,
executor_type,
command_base,
}
}
}
#[async_trait]
@@ -56,7 +112,7 @@ Task title: {}"#,
// Use shell command for cross-platform compatibility
let (shell_cmd, shell_arg) = get_shell_command();
// Pass prompt via stdin instead of command line to avoid shell escaping issues
let claude_command = "npx -y @anthropic-ai/claude-code@latest -p --dangerously-skip-permissions --verbose --output-format=stream-json";
let claude_command = &self.command;
let mut command = Command::new(shell_cmd);
command
@@ -72,9 +128,9 @@ Task title: {}"#,
let mut child = command
.group_spawn() // Create new process group so we can kill entire tree
.map_err(|e| {
crate::executor::SpawnContext::from_command(&command, "Claude")
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context("Claude CLI execution for new task")
.with_context(format!("{} CLI execution for new task", self.executor_type))
.spawn_error(e)
})?;
@@ -87,15 +143,20 @@ Task title: {}"#,
prompt
);
stdin.write_all(prompt.as_bytes()).await.map_err(|e| {
let context = crate::executor::SpawnContext::from_command(&command, "Claude")
.with_task(task_id, Some(task.title.clone()))
.with_context("Failed to write prompt to Claude CLI stdin");
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context(format!(
"Failed to write prompt to {} CLI stdin",
self.executor_type
));
ExecutorError::spawn_failed(e, context)
})?;
stdin.shutdown().await.map_err(|e| {
let context = crate::executor::SpawnContext::from_command(&command, "Claude")
.with_task(task_id, Some(task.title.clone()))
.with_context("Failed to close Claude CLI stdin");
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context(format!("Failed to close {} CLI stdin", self.executor_type));
ExecutorError::spawn_failed(e, context)
})?;
}
@@ -508,10 +569,7 @@ impl Executor for ClaudeFollowupExecutor {
// Use shell command for cross-platform compatibility
let (shell_cmd, shell_arg) = get_shell_command();
// Pass prompt via stdin instead of command line to avoid shell escaping issues
let claude_command = format!(
"npx -y @anthropic-ai/claude-code@latest -p --dangerously-skip-permissions --verbose --output-format=stream-json --resume={}",
self.session_id
);
let claude_command = format!("{} --resume={}", self.command_base, self.session_id);
let mut command = Command::new(shell_cmd);
command
@@ -526,10 +584,10 @@ impl Executor for ClaudeFollowupExecutor {
let mut child = command
.group_spawn() // Create new process group so we can kill entire tree
.map_err(|e| {
crate::executor::SpawnContext::from_command(&command, "Claude")
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"Claude CLI followup execution for session {}",
self.session_id
"{} CLI followup execution for session {}",
self.executor_type, self.session_id
))
.spawn_error(e)
})?;
@@ -538,24 +596,27 @@ impl Executor for ClaudeFollowupExecutor {
if let Some(mut stdin) = child.inner().stdin.take() {
use tokio::io::AsyncWriteExt;
tracing::debug!(
"Writing prompt to Claude stdin for session {}: {:?}",
"Writing prompt to {} stdin for session {}: {:?}",
self.executor_type,
self.session_id,
self.prompt
);
stdin.write_all(self.prompt.as_bytes()).await.map_err(|e| {
let context = crate::executor::SpawnContext::from_command(&command, "Claude")
.with_context(format!(
"Failed to write prompt to Claude CLI stdin for session {}",
self.session_id
));
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"Failed to write prompt to {} CLI stdin for session {}",
self.executor_type, self.session_id
));
ExecutorError::spawn_failed(e, context)
})?;
stdin.shutdown().await.map_err(|e| {
let context = crate::executor::SpawnContext::from_command(&command, "Claude")
.with_context(format!(
"Failed to close Claude CLI stdin for session {}",
self.session_id
));
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"Failed to close {} CLI stdin for session {}",
self.executor_type, self.session_id
));
ExecutorError::spawn_failed(e, context)
})?;
}
@@ -569,7 +630,7 @@ impl Executor for ClaudeFollowupExecutor {
worktree_path: &str,
) -> Result<NormalizedConversation, String> {
// Reuse the same logic as the main ClaudeExecutor
let main_executor = ClaudeExecutor;
let main_executor = ClaudeExecutor::new();
main_executor.normalize_logs(logs, worktree_path)
}
}
@@ -580,7 +641,7 @@ mod tests {
#[test]
fn test_normalize_logs_ignores_result_type() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
let logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114","tools":[],"model":"claude-sonnet-4-20250514"}
{"type":"assistant","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello world"}],"stop_reason":null},"session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":6059,"result":"Final result"}
@@ -606,7 +667,7 @@ mod tests {
#[test]
fn test_make_path_relative() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test with relative path (should remain unchanged)
assert_eq!(
@@ -623,7 +684,7 @@ mod tests {
#[test]
fn test_todo_tool_content_extraction() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test TodoWrite with actual todo list
let todo_input = serde_json::json!({
@@ -666,7 +727,7 @@ mod tests {
#[test]
fn test_todo_tool_empty_list() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test TodoWrite with empty todo list
let empty_input = serde_json::json!({
@@ -687,7 +748,7 @@ mod tests {
#[test]
fn test_todo_tool_no_todos_field() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test TodoWrite with no todos field
let no_todos_input = serde_json::json!({
@@ -708,7 +769,7 @@ mod tests {
#[test]
fn test_glob_tool_content_extraction() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test Glob with pattern and path
let glob_input = serde_json::json!({
@@ -730,7 +791,7 @@ mod tests {
#[test]
fn test_glob_tool_pattern_only() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test Glob with pattern only
let glob_input = serde_json::json!({
@@ -751,7 +812,7 @@ mod tests {
#[test]
fn test_ls_tool_content_extraction() {
let executor = ClaudeExecutor;
let executor = ClaudeExecutor::new();
// Test LS with path
let ls_input = serde_json::json!({

View File

@@ -1,4 +1,5 @@
pub mod amp;
pub mod ccr;
pub mod charm_opencode;
pub mod claude;
pub mod dev_server;
@@ -7,6 +8,7 @@ pub mod gemini;
pub mod setup_script;
pub use amp::{AmpExecutor, AmpFollowupExecutor};
pub use ccr::{CCRExecutor, CCRFollowupExecutor};
pub use charm_opencode::{CharmOpencodeExecutor, CharmOpencodeFollowupExecutor};
pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor};
pub use dev_server::DevServerExecutor;

View File

@@ -636,6 +636,7 @@ impl ProcessService {
fn resolve_executor_config(executor_name: &Option<String>) -> crate::executor::ExecutorConfig {
match executor_name.as_ref().map(|s| s.as_str()) {
Some("claude") => crate::executor::ExecutorConfig::Claude,
Some("claude-code-router") => crate::executor::ExecutorConfig::ClaudeCodeRouter,
Some("amp") => crate::executor::ExecutorConfig::Amp,
Some("gemini") => crate::executor::ExecutorConfig::Gemini,
Some("charmopencode") => crate::executor::ExecutorConfig::CharmOpencode,
@@ -779,17 +780,14 @@ impl ProcessService {
prompt,
} => {
use crate::executors::{
AmpFollowupExecutor, CharmOpencodeFollowupExecutor, ClaudeFollowupExecutor,
GeminiFollowupExecutor,
AmpFollowupExecutor, CCRFollowupExecutor, CharmOpencodeFollowupExecutor,
ClaudeFollowupExecutor, GeminiFollowupExecutor,
};
let executor: Box<dyn crate::executor::Executor> = match config {
crate::executor::ExecutorConfig::Claude => {
if let Some(sid) = session_id {
Box::new(ClaudeFollowupExecutor {
session_id: sid.clone(),
prompt: prompt.clone(),
})
Box::new(ClaudeFollowupExecutor::new(sid.clone(), prompt.clone()))
} else {
return Err(TaskAttemptError::TaskNotFound); // No session ID for followup
}
@@ -825,6 +823,13 @@ impl ProcessService {
return Err(TaskAttemptError::TaskNotFound); // No session ID for followup
}
}
crate::executor::ExecutorConfig::ClaudeCodeRouter => {
if let Some(sid) = session_id {
Box::new(CCRFollowupExecutor::new(sid.clone(), prompt.clone()))
} else {
return Err(TaskAttemptError::TaskNotFound); // No session ID for followup
}
}
crate::executor::ExecutorConfig::SetupScript { .. } => {
// Setup scripts don't support followup, use regular setup script
config.create_executor()