Profile changes (#596)
* Make variants an object rather than array (vibe-kanban 63213864)
Profile variants should be an object, with key used instead of the current label.
The code should be refactored leaving no legacy trace.
crates/server/src/routes/config.rs
crates/executors/src/profile.rs
* Make variants an object rather than array (vibe-kanban 63213864)
Profile variants should be an object, with key used instead of the current label.
The code should be refactored leaving no legacy trace.
crates/server/src/routes/config.rs
crates/executors/src/profile.rs
* Remove the command builder from profiles (vibe-kanban d30abc92)
It should no longer be possible to customise the command builder in profiles.json.
Instead, anywhere where the command is customised, the code should be hardcoded as an enum field on the executor, eg claude code vs claude code router on the claude code struct.
crates/executors/src/profile.rs
crates/executors/src/executors/claude.rs
* fmt
* Refactor Qwen log normalization (vibe-kanban 076fdb3f)
Qwen basically uses the same log normalization as Gemini, can you refactor the code to make it more reusable.
A similar example exists in Amp, where we use Claude's log normalization.
crates/executors/src/executors/amp.rs
crates/executors/src/executors/qwen.rs
crates/executors/src/executors/claude.rs
* Add field overrides to executors (vibe-kanban cc3323a4)
We should add optional fields 'base_command_override' (String) and 'additional_params' (Vec<String>) to each executor, and wire these fields up to the command builder
* Update profiles (vibe-kanban e7545ab6)
Redesign the profile configuration storage system to store only differences from defaults instead of complete profile files. Implement partial profile functions (create_partial_profile, load_from_partials, save_as_diffs) that save human-readable partial profiles containing only changed values. Update ProfileConfigs::load() to handle the new partial format while maintaining backward compatibility with legacy full profile formats through automatic migration that creates backups. Implement smart variants handling that only stores changed, added, or removed variants rather than entire arrays. Fix the profile API consistency issue by replacing the manual file loading logic in the get_profiles() endpoint in crates/server/src/routes/config.rs with ProfileConfigs::get_cached() to ensure the GET endpoint uses the same cached data that PUT updates. Add comprehensive test coverage for all new functionality.
* Yolo mode becomes a field (vibe-kanban d8dd02f0)
Most executors implement some variation of yolo-mode, can you make this boolean field on each executor (if supported), where the name for the field aligns with the CLI field
* Change ClaudeCodeVariant to boolean (vibe-kanban cc05956f)
Instead of an enum ClaudeCodeVariant, let's use a variable claude_code_router to determine whether to use claude_code_router's command. If the user has also supplied a base_command_override this should take precedence (also write a warning to console)
crates/executors/src/executors/claude.rs
* Remove mcp_config_path from profile config (vibe-kanban 6c1e5947)
crates/executors/src/profile.rs
* One profile per executor (vibe-kanban b0adc27e)
Currently you can define arbitrary profiles, multiple profiles per executor. Let's refactor to simplify this configuration, instead we should only be able to configure one profile per executor.
The new format should be something like:
```json
{
"profiles": {
"CLAUDE_CODE": {
"default": {
"plan": false,
"dangerously_skip_permissions": true,
"append_prompt": null
},
"plan": {
"plan": true,
"dangerously_skip_permissions": false,
"append_prompt": null
}
}
}
}
```
Each profile's defaults should be defined as code instead of in default_profiles.json
profile.json will now contain:
- Overrides for default configurations
- Additional user defined configurations, for executors
It is not possible to remove a default configuration entirely, just override the configuration.
The user profile.json should still be a minimal set of overrides, to make upgrading easy.
Don't worry about migration, this will be done manually.
crates/executors/default_profiles.json
crates/executors/src/profile.rs
* SCREAMING_SNAKE_CASE
* update profile.rs
* config migration
* fmt
* delete binding
* config keys
* fmt
* shared types
* Profile variants should be saved as SCREAMING_SNAKE_CASE (vibe-kanban 5c6c124c)
crates/executors/src/profile.rs save_overrides
* rename default profiles
* remove defaulted executor fields
* backwards compatability
* fix legacy variants
This commit is contained in:
committed by
GitHub
parent
a10d3f0854
commit
6a7818e057
@@ -35,3 +35,6 @@ bon = "3.6"
|
||||
fork_stream = "0.1.0"
|
||||
os_pipe = "1.2"
|
||||
strip-ansi-escapes = "0.2.1"
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
convert_case = "0.6"
|
||||
|
||||
@@ -1,154 +1,69 @@
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"label": "claude-code",
|
||||
"mcp_config_path": null,
|
||||
"CLAUDE_CODE": {
|
||||
"command": {
|
||||
"base": "npx -y @anthropic-ai/claude-code@latest",
|
||||
"params": [
|
||||
"-p",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
"--output-format=stream-json"
|
||||
]
|
||||
},
|
||||
"plan": false
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"label": "plan",
|
||||
"mcp_config_path": null,
|
||||
"CLAUDE_CODE": {
|
||||
"command": {
|
||||
"base": "npx -y @anthropic-ai/claude-code@latest",
|
||||
"params": [
|
||||
"-p",
|
||||
"--permission-mode=plan",
|
||||
"--verbose",
|
||||
"--output-format=stream-json"
|
||||
]
|
||||
},
|
||||
"plan": true
|
||||
}
|
||||
"executors": {
|
||||
"CLAUDE_CODE": {
|
||||
"DEFAULT": {
|
||||
"CLAUDE_CODE": {
|
||||
"dangerously_skip_permissions": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"PLAN": {
|
||||
"CLAUDE_CODE": {
|
||||
"plan": true
|
||||
}
|
||||
},
|
||||
"ROUTER": {
|
||||
"CLAUDE_CODE": {
|
||||
"claude_code_router": true,
|
||||
"dangerously_skip_permissions": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "claude-code-router",
|
||||
"mcp_config_path": null,
|
||||
"CLAUDE_CODE": {
|
||||
"command": {
|
||||
"base": "npx -y @musistudio/claude-code-router code",
|
||||
"params": [
|
||||
"-p",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
"--output-format=stream-json"
|
||||
]
|
||||
},
|
||||
"plan": false
|
||||
},
|
||||
"variants": []
|
||||
"AMP": {
|
||||
"DEFAULT": {
|
||||
"AMP": {
|
||||
"dangerously_allow_all": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "amp",
|
||||
"mcp_config_path": null,
|
||||
"AMP": {
|
||||
"command": {
|
||||
"base": "npx -y @sourcegraph/amp@latest",
|
||||
"params": [
|
||||
"--execute",
|
||||
"--stream-json",
|
||||
"--dangerously-allow-all"
|
||||
]
|
||||
"GEMINI": {
|
||||
"DEFAULT": {
|
||||
"GEMINI": {
|
||||
"model": "default",
|
||||
"yolo": true
|
||||
}
|
||||
},
|
||||
"variants": []
|
||||
"FLASH": {
|
||||
"GEMINI": {
|
||||
"model": "flash",
|
||||
"yolo": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "gemini",
|
||||
"mcp_config_path": null,
|
||||
"GEMINI": {
|
||||
"command": {
|
||||
"base": "npx -y @google/gemini-cli@latest",
|
||||
"params": [
|
||||
"--yolo"
|
||||
]
|
||||
"CODEX": {
|
||||
"DEFAULT": {
|
||||
"CODEX": {
|
||||
"dangerously_bypass_approvals_and_sandbox": true
|
||||
}
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"label": "flash",
|
||||
"mcp_config_path": null,
|
||||
"GEMINI": {
|
||||
"command": {
|
||||
"base": "npx -y @google/gemini-cli@latest",
|
||||
"params": [
|
||||
"--yolo",
|
||||
"--model",
|
||||
"gemini-2.5-flash"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "codex",
|
||||
"mcp_config_path": null,
|
||||
"CODEX": {
|
||||
"command": {
|
||||
"base": "npx -y @openai/codex exec",
|
||||
"params": [
|
||||
"--json",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check"
|
||||
]
|
||||
}
|
||||
},
|
||||
"variants": []
|
||||
"OPENCODE": {
|
||||
"DEFAULT": {
|
||||
"OPENCODE": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "opencode",
|
||||
"mcp_config_path": null,
|
||||
"OPENCODE": {
|
||||
"command": {
|
||||
"base": "npx -y opencode-ai@latest run",
|
||||
"params": [
|
||||
"--print-logs"
|
||||
]
|
||||
"QWEN_CODE": {
|
||||
"DEFAULT": {
|
||||
"QWEN_CODE": {
|
||||
"yolo": true
|
||||
}
|
||||
},
|
||||
"variants": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "qwen-code",
|
||||
"mcp_config_path": "~/.qwen/settings.json",
|
||||
"GEMINI": {
|
||||
"command": {
|
||||
"base": "npx -y @qwen-code/qwen-code@latest",
|
||||
"params": [
|
||||
"--yolo"
|
||||
]
|
||||
"CURSOR": {
|
||||
"DEFAULT": {
|
||||
"CURSOR": {
|
||||
"force": true
|
||||
}
|
||||
},
|
||||
"variants": []
|
||||
},
|
||||
{
|
||||
"label": "cursor",
|
||||
"mcp_config_path": null,
|
||||
"CURSOR": {
|
||||
"command": {
|
||||
"base": "cursor-agent",
|
||||
"params": [
|
||||
"-p",
|
||||
"--output-format=stream-json",
|
||||
"--force"
|
||||
]
|
||||
}
|
||||
},
|
||||
"variants": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,31 @@ use ts_rs::TS;
|
||||
use crate::{
|
||||
actions::Executable,
|
||||
executors::{CodingAgent, ExecutorError, StandardCodingAgentExecutor},
|
||||
profile::ProfileVariantLabel,
|
||||
profile::ExecutorProfileId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct CodingAgentFollowUpRequest {
|
||||
pub prompt: String,
|
||||
pub session_id: String,
|
||||
pub profile_variant_label: ProfileVariantLabel,
|
||||
/// Executor profile specification
|
||||
#[serde(alias = "profile_variant_label")]
|
||||
// Backwards compatability with ProfileVariantIds, esp stored in DB under ExecutorAction
|
||||
pub executor_profile_id: ExecutorProfileId,
|
||||
}
|
||||
|
||||
impl CodingAgentFollowUpRequest {
|
||||
/// Get the executor profile ID
|
||||
pub fn get_executor_profile_id(&self) -> ExecutorProfileId {
|
||||
self.executor_profile_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for CodingAgentFollowUpRequest {
|
||||
async fn spawn(&self, current_dir: &PathBuf) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let agent = CodingAgent::from_profile_variant_label(&self.profile_variant_label)?;
|
||||
let executor_profile_id = self.get_executor_profile_id();
|
||||
let agent = CodingAgent::from_executor_profile_id(&executor_profile_id)?;
|
||||
|
||||
agent
|
||||
.spawn_follow_up(current_dir, &self.prompt, &self.session_id)
|
||||
|
||||
@@ -8,19 +8,30 @@ use ts_rs::TS;
|
||||
use crate::{
|
||||
actions::Executable,
|
||||
executors::{CodingAgent, ExecutorError, StandardCodingAgentExecutor},
|
||||
profile::ProfileVariantLabel,
|
||||
profile::ExecutorProfileId,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct CodingAgentInitialRequest {
|
||||
pub prompt: String,
|
||||
pub profile_variant_label: ProfileVariantLabel,
|
||||
/// Executor profile specification
|
||||
#[serde(alias = "profile_variant_label")]
|
||||
// Backwards compatability with ProfileVariantIds, esp stored in DB under ExecutorAction
|
||||
pub executor_profile_id: ExecutorProfileId,
|
||||
}
|
||||
|
||||
impl CodingAgentInitialRequest {
|
||||
/// Get the executor profile ID
|
||||
pub fn get_executor_profile_id(&self) -> ExecutorProfileId {
|
||||
self.executor_profile_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for CodingAgentInitialRequest {
|
||||
async fn spawn(&self, current_dir: &PathBuf) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let agent = CodingAgent::from_profile_variant_label(&self.profile_variant_label)?;
|
||||
let executor_profile_id = self.get_executor_profile_id();
|
||||
let agent = CodingAgent::from_executor_profile_id(&executor_profile_id)?;
|
||||
agent.spawn(current_dir, &self.prompt).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct CmdOverrides {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_command_override: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub additional_params: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct CommandBuilder {
|
||||
/// Base executable command (e.g., "npx -y @anthropic-ai/claude-code@latest")
|
||||
@@ -25,6 +33,24 @@ impl CommandBuilder {
|
||||
self.params = Some(params.into_iter().map(|p| p.into()).collect());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn override_base<S: Into<String>>(mut self, base: S) -> Self {
|
||||
self.base = base.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn extend_params<I>(mut self, more: I) -> Self
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: Into<String>,
|
||||
{
|
||||
let extra: Vec<String> = more.into_iter().map(|p| p.into()).collect();
|
||||
match &mut self.params {
|
||||
Some(p) => p.extend(extra),
|
||||
None => self.params = Some(extra),
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn build_initial(&self) -> String {
|
||||
let mut parts = vec![self.base.clone()];
|
||||
if let Some(ref params) = self.params {
|
||||
@@ -42,3 +68,16 @@ impl CommandBuilder {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_overrides(builder: CommandBuilder, overrides: &CmdOverrides) -> CommandBuilder {
|
||||
let builder = if let Some(ref base) = overrides.base_command_override {
|
||||
builder.override_base(base.clone())
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
if let Some(ref extra) = overrides.additional_params {
|
||||
builder.extend_params(extra.clone())
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use ts_rs::TS;
|
||||
use utils::{msg_store::MsgStore, shell::get_shell_command};
|
||||
|
||||
use crate::{
|
||||
command::CommandBuilder,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
executors::{
|
||||
ExecutorError, StandardCodingAgentExecutor,
|
||||
claude::{ClaudeLogProcessor, HistoryStrategy},
|
||||
@@ -19,8 +19,23 @@ use crate::{
|
||||
/// An executor that uses Amp to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Amp {
|
||||
pub command: CommandBuilder,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dangerously_allow_all: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
}
|
||||
|
||||
impl Amp {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
let mut builder = CommandBuilder::new("npx -y @sourcegraph/amp@latest")
|
||||
.params(["--execute", "--stream-json"]);
|
||||
if self.dangerously_allow_all.unwrap_or(false) {
|
||||
builder = builder.params(["--dangerously-allow-all"]);
|
||||
}
|
||||
apply_overrides(builder, &self.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -31,7 +46,8 @@ impl StandardCodingAgentExecutor for Amp {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let amp_command = self.command.build_initial();
|
||||
let amp_command = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
@@ -63,7 +79,7 @@ impl StandardCodingAgentExecutor for Amp {
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
// Use shell command for cross-platform compatibility
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let amp_command = self.command.build_follow_up(&[
|
||||
let amp_command = self.build_command_builder().build_follow_up(&[
|
||||
"threads".to_string(),
|
||||
"continue".to_string(),
|
||||
session_id.to_string(),
|
||||
@@ -106,4 +122,9 @@ impl StandardCodingAgentExecutor for Amp {
|
||||
// Process stderr logs using the standard stderr processor
|
||||
normalize_stderr_logs(msg_store, entry_index_provider);
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::config_dir().map(|config| config.join("amp").join("settings.json"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use utils::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
command::CommandBuilder,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
executors::{ExecutorError, StandardCodingAgentExecutor},
|
||||
logs::{
|
||||
ActionType, FileChange, NormalizedEntry, NormalizedEntryType, TodoItem,
|
||||
@@ -24,12 +24,64 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
fn base_command(claude_code_router: bool) -> &'static str {
|
||||
if claude_code_router {
|
||||
"npx -y @musistudio/claude-code-router code"
|
||||
} else {
|
||||
"npx -y @anthropic-ai/claude-code@latest"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_builder(
|
||||
claude_code_router: bool,
|
||||
plan: bool,
|
||||
dangerously_skip_permissions: bool,
|
||||
) -> CommandBuilder {
|
||||
let mut params: Vec<&'static str> = vec!["-p"];
|
||||
if plan {
|
||||
params.push("--permission-mode=plan");
|
||||
}
|
||||
if dangerously_skip_permissions {
|
||||
params.push("--dangerously-skip-permissions");
|
||||
}
|
||||
params.extend_from_slice(&["--verbose", "--output-format=stream-json"]);
|
||||
|
||||
CommandBuilder::new(base_command(claude_code_router)).params(params)
|
||||
}
|
||||
|
||||
/// An executor that uses Claude CLI to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ClaudeCode {
|
||||
pub command: CommandBuilder,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_code_router: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
pub plan: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plan: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dangerously_skip_permissions: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
}
|
||||
|
||||
impl ClaudeCode {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
// If base_command_override is provided and claude_code_router is also set, log a warning
|
||||
if self.cmd.base_command_override.is_some() && self.claude_code_router.is_some() {
|
||||
tracing::warn!(
|
||||
"base_command_override is set, this will override the claude_code_router setting"
|
||||
);
|
||||
}
|
||||
|
||||
apply_overrides(
|
||||
build_command_builder(
|
||||
self.claude_code_router.unwrap_or(false),
|
||||
self.plan.unwrap_or(false),
|
||||
self.dangerously_skip_permissions.unwrap_or(false),
|
||||
),
|
||||
&self.cmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -40,11 +92,12 @@ impl StandardCodingAgentExecutor for ClaudeCode {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let claude_command = if self.plan {
|
||||
let base_command = self.command.build_initial();
|
||||
let command_builder = self.build_command_builder();
|
||||
let base_command = command_builder.build_initial();
|
||||
let claude_command = if self.plan.unwrap_or(false) {
|
||||
create_watchkill_script(&base_command)
|
||||
} else {
|
||||
self.command.build_initial()
|
||||
base_command
|
||||
};
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
@@ -77,15 +130,14 @@ impl StandardCodingAgentExecutor for ClaudeCode {
|
||||
session_id: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let command_builder = self.build_command_builder();
|
||||
// Build follow-up command with --resume {session_id}
|
||||
let claude_command = if self.plan {
|
||||
let base_command = self
|
||||
.command
|
||||
.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
|
||||
let base_command =
|
||||
command_builder.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
|
||||
let claude_command = if self.plan.unwrap_or(false) {
|
||||
create_watchkill_script(&base_command)
|
||||
} else {
|
||||
self.command
|
||||
.build_follow_up(&["--resume".to_string(), session_id.to_string()])
|
||||
base_command
|
||||
};
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
@@ -125,10 +177,15 @@ impl StandardCodingAgentExecutor for ClaudeCode {
|
||||
// Process stderr logs using the standard stderr processor
|
||||
normalize_stderr_logs(msg_store, entry_index_provider);
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".claude.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_watchkill_script(command: &str) -> String {
|
||||
let claude_plan_stop_indicator = concat!("Exit ", "plan mode?"); // Use concat!() as a workaround to avoid killing plan mode when this file is read.
|
||||
let claude_plan_stop_indicator = concat!("Exit ", "plan mode?");
|
||||
format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -1455,9 +1512,14 @@ mod tests {
|
||||
use utils::msg_store::MsgStore;
|
||||
|
||||
let executor = ClaudeCode {
|
||||
command: CommandBuilder::new(""),
|
||||
plan: false,
|
||||
claude_code_router: Some(false),
|
||||
plan: None,
|
||||
append_prompt: None,
|
||||
dangerously_skip_permissions: None,
|
||||
cmd: crate::command::CmdOverrides {
|
||||
base_command_override: None,
|
||||
additional_params: None,
|
||||
},
|
||||
};
|
||||
let msg_store = Arc::new(MsgStore::new());
|
||||
let current_dir = std::path::PathBuf::from("/tmp/test-worktree");
|
||||
|
||||
@@ -107,8 +107,24 @@ impl SessionHandler {
|
||||
/// An executor that uses Codex CLI to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Codex {
|
||||
pub command: CommandBuilder,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dangerously_bypass_approvals_and_sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
impl Codex {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
let mut builder = CommandBuilder::new("npx -y @openai/codex exec")
|
||||
.params(["--json", "--skip-git-repo-check"]);
|
||||
if self
|
||||
.dangerously_bypass_approvals_and_sandbox
|
||||
.unwrap_or(false)
|
||||
{
|
||||
builder = builder.params(["--dangerously-bypass-approvals-and-sandbox"]);
|
||||
}
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -119,7 +135,7 @@ impl StandardCodingAgentExecutor for Codex {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let codex_command = self.command.build_initial();
|
||||
let codex_command = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
@@ -159,7 +175,7 @@ impl StandardCodingAgentExecutor for Codex {
|
||||
})?;
|
||||
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let codex_command = self.command.build_follow_up(&[
|
||||
let codex_command = self.build_command_builder().build_follow_up(&[
|
||||
"-c".to_string(),
|
||||
format!("experimental_resume={}", rollout_file_path.display()),
|
||||
]);
|
||||
@@ -421,6 +437,11 @@ impl StandardCodingAgentExecutor for Codex {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".codex").join("config.toml"))
|
||||
}
|
||||
}
|
||||
|
||||
// Data structures for parsing Codex's JSON output format
|
||||
|
||||
@@ -30,8 +30,21 @@ use crate::{
|
||||
/// Executor for running Cursor CLI and normalizing its JSONL stream
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Cursor {
|
||||
pub command: CommandBuilder,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
let mut builder =
|
||||
CommandBuilder::new("cursor-agent").params(["-p", "--output-format=stream-json"]);
|
||||
if self.force.unwrap_or(false) {
|
||||
builder = builder.params(["--force"]);
|
||||
}
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -42,7 +55,7 @@ impl StandardCodingAgentExecutor for Cursor {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let agent_cmd = self.command.build_initial();
|
||||
let agent_cmd = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
@@ -74,7 +87,7 @@ impl StandardCodingAgentExecutor for Cursor {
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let agent_cmd = self
|
||||
.command
|
||||
.build_command_builder()
|
||||
.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
@@ -377,6 +390,11 @@ impl StandardCodingAgentExecutor for Cursor {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_cursor_ascii_art_banner(line: String) -> String {
|
||||
@@ -1039,8 +1057,9 @@ mod tests {
|
||||
async fn test_cursor_streaming_patch_generation() {
|
||||
// Avoid relying on feature flag in tests; construct with a dummy command
|
||||
let executor = Cursor {
|
||||
command: CommandBuilder::new(""),
|
||||
// No command field needed anymore
|
||||
append_prompt: None,
|
||||
force: None,
|
||||
};
|
||||
let msg_store = Arc::new(MsgStore::new());
|
||||
let current_dir = std::path::PathBuf::from("/tmp/test-worktree");
|
||||
|
||||
@@ -13,7 +13,7 @@ use ts_rs::TS;
|
||||
use utils::{msg_store::MsgStore, shell::get_shell_command};
|
||||
|
||||
use crate::{
|
||||
command::CommandBuilder,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
executors::{ExecutorError, StandardCodingAgentExecutor},
|
||||
logs::{
|
||||
NormalizedEntry, NormalizedEntryType, plain_text_processor::PlainTextLogProcessor,
|
||||
@@ -22,11 +22,52 @@ use crate::{
|
||||
stdout_dup,
|
||||
};
|
||||
|
||||
/// Model variant of Gemini to use
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GeminiModel {
|
||||
Default, // no --model flag
|
||||
Flash, // --model gemini-2.5-flash
|
||||
}
|
||||
|
||||
impl GeminiModel {
|
||||
fn base_command(&self) -> &'static str {
|
||||
"npx -y @google/gemini-cli@latest"
|
||||
}
|
||||
|
||||
fn build_command_builder(&self, yolo: bool) -> CommandBuilder {
|
||||
let mut params: Vec<&'static str> = vec![];
|
||||
if yolo {
|
||||
params.push("--yolo");
|
||||
}
|
||||
|
||||
if let GeminiModel::Flash = self {
|
||||
params.extend_from_slice(&["--model", "gemini-2.5-flash"]);
|
||||
}
|
||||
|
||||
CommandBuilder::new(self.base_command()).params(params)
|
||||
}
|
||||
}
|
||||
|
||||
/// An executor that uses Gemini to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Gemini {
|
||||
pub command: CommandBuilder,
|
||||
pub model: GeminiModel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub yolo: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
apply_overrides(
|
||||
self.model.build_command_builder(self.yolo.unwrap_or(false)),
|
||||
&self.cmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -37,7 +78,7 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let gemini_command = self.command.build_initial();
|
||||
let gemini_command = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
@@ -82,7 +123,7 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
let followup_prompt = self.build_followup_prompt(current_dir, prompt).await?;
|
||||
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let gemini_command = self.command.build_follow_up(&[]);
|
||||
let gemini_command = self.build_command_builder().build_follow_up(&[]);
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
|
||||
@@ -152,24 +193,7 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
let mut stdout = msg_store.stdout_chunked_stream();
|
||||
|
||||
// Create a processor with Gemini-specific formatting
|
||||
let mut processor = PlainTextLogProcessor::builder()
|
||||
.normalized_entry_producer(Box::new(|content: String| NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::AssistantMessage,
|
||||
content,
|
||||
metadata: None,
|
||||
}))
|
||||
.format_chunk(Box::new(|partial_line: Option<&str>, chunk: String| {
|
||||
Self::format_stdout_chunk(&chunk, partial_line.unwrap_or(""))
|
||||
}))
|
||||
// Gemini CLI sometimes prints a non-conversational noise
|
||||
.transform_lines({
|
||||
Box::new(move |lines: &mut Vec<String>| {
|
||||
lines.retain(|line| line != "Data collection is disabled.\n");
|
||||
})
|
||||
})
|
||||
.index_provider(entry_index_counter)
|
||||
.build();
|
||||
let mut processor = Self::create_gemini_style_processor(entry_index_counter);
|
||||
|
||||
while let Some(Ok(chunk)) = stdout.next().await {
|
||||
for patch in processor.process(chunk) {
|
||||
@@ -178,9 +202,38 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".gemini").join("settings.json"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
/// Creates a PlainTextLogProcessor that applies Gemini's sentence-break heuristics.
|
||||
///
|
||||
/// This processor formats chunks by inserting line breaks at period-to-capital transitions
|
||||
/// and filters out Gemini CLI noise messages.
|
||||
pub(crate) fn create_gemini_style_processor(
|
||||
index_provider: EntryIndexProvider,
|
||||
) -> PlainTextLogProcessor {
|
||||
PlainTextLogProcessor::builder()
|
||||
.normalized_entry_producer(Box::new(|content: String| NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::AssistantMessage,
|
||||
content,
|
||||
metadata: None,
|
||||
}))
|
||||
.format_chunk(Box::new(|partial, chunk| {
|
||||
Self::format_stdout_chunk(&chunk, partial.unwrap_or(""))
|
||||
}))
|
||||
.transform_lines(Box::new(|lines: &mut Vec<String>| {
|
||||
lines.retain(|l| l != "Data collection is disabled.\n");
|
||||
}))
|
||||
.index_provider(index_provider)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Make Gemini output more readable by inserting line breaks where periods are directly
|
||||
/// followed by capital letters (common Gemini CLI formatting issue).
|
||||
/// Handles both intra-chunk and cross-chunk period-to-capital transitions.
|
||||
|
||||
@@ -5,6 +5,7 @@ use command_group::AsyncGroupChild;
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use futures_io::Error as FuturesIoError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum_macros::{Display, VariantNames};
|
||||
use thiserror::Error;
|
||||
use ts_rs::TS;
|
||||
use utils::msg_store::MsgStore;
|
||||
@@ -12,10 +13,10 @@ use utils::msg_store::MsgStore;
|
||||
use crate::{
|
||||
executors::{
|
||||
amp::Amp, claude::ClaudeCode, codex::Codex, cursor::Cursor, gemini::Gemini,
|
||||
opencode::Opencode,
|
||||
opencode::Opencode, qwen::QwenCode,
|
||||
},
|
||||
mcp_config::McpConfig,
|
||||
profile::{ProfileConfigs, ProfileVariantLabel},
|
||||
profile::{ExecutorProfileConfigs, ExecutorProfileId},
|
||||
};
|
||||
|
||||
pub mod amp;
|
||||
@@ -24,6 +25,7 @@ pub mod codex;
|
||||
pub mod cursor;
|
||||
pub mod gemini;
|
||||
pub mod opencode;
|
||||
pub mod qwen;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExecutorError {
|
||||
@@ -44,8 +46,9 @@ pub enum ExecutorError {
|
||||
}
|
||||
|
||||
#[enum_dispatch]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Display, VariantNames)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum CodingAgent {
|
||||
ClaudeCode,
|
||||
Amp,
|
||||
@@ -53,38 +56,21 @@ pub enum CodingAgent {
|
||||
Codex,
|
||||
Opencode,
|
||||
Cursor,
|
||||
QwenCode,
|
||||
}
|
||||
|
||||
impl CodingAgent {
|
||||
/// Create a CodingAgent from a profile variant
|
||||
/// Loads profile from AgentProfiles (both default and custom profiles)
|
||||
pub fn from_profile_variant_label(
|
||||
profile_variant_label: &ProfileVariantLabel,
|
||||
/// Create a CodingAgent from an executor profile ID
|
||||
pub fn from_executor_profile_id(
|
||||
executor_profile_id: &ExecutorProfileId,
|
||||
) -> Result<Self, ExecutorError> {
|
||||
if let Some(profile_config) =
|
||||
ProfileConfigs::get_cached().get_profile(&profile_variant_label.profile)
|
||||
{
|
||||
if let Some(variant_name) = &profile_variant_label.variant {
|
||||
if let Some(variant) = profile_config.get_variant(variant_name) {
|
||||
Ok(variant.agent.clone())
|
||||
} else {
|
||||
Err(ExecutorError::UnknownExecutorType(format!(
|
||||
"Unknown mode: {variant_name}"
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Ok(profile_config.default.agent.clone())
|
||||
}
|
||||
} else {
|
||||
Err(ExecutorError::UnknownExecutorType(format!(
|
||||
"Unknown profile: {}",
|
||||
profile_variant_label.profile
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_mcp(&self) -> bool {
|
||||
self.default_mcp_config_path().is_some()
|
||||
ExecutorProfileConfigs::get_cached()
|
||||
.get_agent_by_id(executor_profile_id)
|
||||
.ok_or_else(|| {
|
||||
ExecutorError::UnknownExecutorType(format!(
|
||||
"Unknown executor profile: {executor_profile_id}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mcp_config(&self) -> McpConfig {
|
||||
@@ -140,32 +126,19 @@ impl CodingAgent {
|
||||
|
||||
pub fn default_mcp_config_path(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
//ExecutorConfig::CharmOpencode => {
|
||||
//dirs::home_dir().map(|home| home.join(".opencode.json"))
|
||||
//}
|
||||
Self::ClaudeCode(_) => dirs::home_dir().map(|home| home.join(".claude.json")),
|
||||
//ExecutorConfig::ClaudePlan => dirs::home_dir().map(|home| home.join(".claude.json")),
|
||||
Self::Opencode(_) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
xdg::BaseDirectories::with_prefix("opencode").get_config_file("opencode.json")
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
dirs::config_dir().map(|config| config.join("opencode").join("opencode.json"))
|
||||
}
|
||||
}
|
||||
//ExecutorConfig::Aider => None,
|
||||
Self::Codex(_) => dirs::home_dir().map(|home| home.join(".codex").join("config.toml")),
|
||||
Self::Amp(_) => {
|
||||
dirs::config_dir().map(|config| config.join("amp").join("settings.json"))
|
||||
}
|
||||
Self::Gemini(_) => {
|
||||
dirs::home_dir().map(|home| home.join(".gemini").join("settings.json"))
|
||||
}
|
||||
Self::Cursor(_) => dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
|
||||
Self::ClaudeCode(agent) => agent.default_mcp_config_path(),
|
||||
Self::Amp(agent) => agent.default_mcp_config_path(),
|
||||
Self::Gemini(agent) => agent.default_mcp_config_path(),
|
||||
Self::Codex(agent) => agent.default_mcp_config_path(),
|
||||
Self::Opencode(agent) => agent.default_mcp_config_path(),
|
||||
Self::Cursor(agent) => agent.default_mcp_config_path(),
|
||||
Self::QwenCode(agent) => agent.default_mcp_config_path(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_mcp(&self) -> bool {
|
||||
self.default_mcp_config_path().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -183,4 +156,7 @@ pub trait StandardCodingAgentExecutor {
|
||||
session_id: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError>;
|
||||
fn normalize_logs(&self, _raw_logs_event_store: Arc<MsgStore>, _worktree_path: &PathBuf);
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf>;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,16 @@ use crate::{
|
||||
/// An executor that uses OpenCode to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Opencode {
|
||||
pub command: CommandBuilder,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl Opencode {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
CommandBuilder::new("npx -y opencode-ai@latest run").params(["--print-logs"])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for Opencode {
|
||||
async fn spawn(
|
||||
@@ -39,7 +45,7 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let opencode_command = self.command.build_initial();
|
||||
let opencode_command = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
@@ -73,7 +79,7 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let opencode_command = self
|
||||
.command
|
||||
.build_command_builder()
|
||||
.build_follow_up(&["--session".to_string(), session_id.to_string()]);
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
@@ -151,6 +157,18 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
msg_store,
|
||||
));
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
xdg::BaseDirectories::with_prefix("opencode").get_config_file("opencode.json")
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
dirs::config_dir().map(|config| config.join("opencode").join("opencode.json"))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Opencode {
|
||||
async fn process_opencode_log_lines(
|
||||
|
||||
137
crates/executors/src/executors/qwen.rs
Normal file
137
crates/executors/src/executors/qwen.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::{path::PathBuf, process::Stdio, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
use ts_rs::TS;
|
||||
use utils::{msg_store::MsgStore, shell::get_shell_command};
|
||||
|
||||
use crate::{
|
||||
command::CommandBuilder,
|
||||
executors::{ExecutorError, StandardCodingAgentExecutor, gemini::Gemini},
|
||||
logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider},
|
||||
};
|
||||
|
||||
/// An executor that uses QwenCode CLI to process tasks
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct QwenCode {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub append_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub yolo: Option<bool>,
|
||||
}
|
||||
|
||||
impl QwenCode {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
let mut builder = CommandBuilder::new("npx -y @qwen-code/qwen-code@latest");
|
||||
if self.yolo.unwrap_or(false) {
|
||||
builder = builder.params(["--yolo"]);
|
||||
}
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for QwenCode {
|
||||
async fn spawn(
|
||||
&self,
|
||||
current_dir: &PathBuf,
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let qwen_command = self.build_command_builder().build_initial();
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(current_dir)
|
||||
.arg(shell_arg)
|
||||
.arg(&qwen_command);
|
||||
|
||||
let mut child = command.group_spawn()?;
|
||||
|
||||
// Feed the prompt in, then close the pipe
|
||||
if let Some(mut stdin) = child.inner().stdin.take() {
|
||||
stdin.write_all(combined_prompt.as_bytes()).await?;
|
||||
stdin.shutdown().await?;
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn spawn_follow_up(
|
||||
&self,
|
||||
current_dir: &PathBuf,
|
||||
prompt: &str,
|
||||
session_id: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let qwen_command = self
|
||||
.build_command_builder()
|
||||
.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
|
||||
|
||||
let combined_prompt = utils::text::combine_prompt(&self.append_prompt, prompt);
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(current_dir)
|
||||
.arg(shell_arg)
|
||||
.arg(&qwen_command);
|
||||
|
||||
let mut child = command.group_spawn()?;
|
||||
|
||||
// Feed the followup prompt in, then close the pipe
|
||||
if let Some(mut stdin) = child.inner().stdin.take() {
|
||||
stdin.write_all(combined_prompt.as_bytes()).await?;
|
||||
stdin.shutdown().await?;
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn normalize_logs(&self, msg_store: Arc<MsgStore>, current_dir: &PathBuf) {
|
||||
// QwenCode has similar output format to Gemini CLI
|
||||
// Use Gemini's proven sentence-break formatting instead of simple replace
|
||||
let entry_index_counter = EntryIndexProvider::start_from(&msg_store);
|
||||
normalize_stderr_logs(msg_store.clone(), entry_index_counter.clone());
|
||||
|
||||
// Send session ID to msg_store to enable follow-ups
|
||||
msg_store.push_session_id(
|
||||
current_dir
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
// Use Gemini's log processor for consistent formatting
|
||||
tokio::spawn(async move {
|
||||
use futures::StreamExt;
|
||||
let mut stdout = msg_store.stdout_chunked_stream();
|
||||
|
||||
// Use Gemini's proven sentence-break heuristics
|
||||
let mut processor = Gemini::create_gemini_style_processor(entry_index_counter);
|
||||
|
||||
while let Some(Ok(chunk)) = stdout.next().await {
|
||||
for patch in processor.process(chunk) {
|
||||
msg_store.push_patch(patch);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// MCP configuration methods
|
||||
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".qwen").join("settings.json"))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user