diff --git a/crates/executors/default_profiles.json b/crates/executors/default_profiles.json index 89393bce..236f9a10 100644 --- a/crates/executors/default_profiles.json +++ b/crates/executors/default_profiles.json @@ -119,6 +119,31 @@ "model": "grok" } } + }, + "COPILOT": { + "DEFAULT": { + "COPILOT": { + "allow_all_tools": true + } + }, + "GPT_5": { + "COPILOT": { + "allow_all_tools": true, + "model": "gpt-5" + } + }, + "CLAUDE_SONNET_4_5": { + "COPILOT": { + "allow_all_tools": true, + "model": "claude-sonnet-4.5" + } + }, + "CLAUDE_SONNET_4": { + "COPILOT": { + "allow_all_tools": true, + "model": "claude-sonnet-4" + } + } } } } diff --git a/crates/executors/src/executors/copilot.rs b/crates/executors/src/executors/copilot.rs new file mode 100644 index 00000000..4a5463d7 --- /dev/null +++ b/crates/executors/src/executors/copilot.rs @@ -0,0 +1,280 @@ +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use command_group::AsyncCommandGroup; +use futures::StreamExt; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tokio::{ + fs, + io::AsyncWriteExt, + process::Command, + time::{interval, timeout}, +}; +use ts_rs::TS; +use uuid::Uuid; +use workspace_utils::{ + msg_store::MsgStore, path::get_vibe_kanban_temp_dir, shell::get_shell_command, +}; + +use crate::{ + command::{CmdOverrides, CommandBuilder, apply_overrides}, + executors::{AppendPrompt, ExecutorError, SpawnedChild, StandardCodingAgentExecutor}, + logs::{ + NormalizedEntry, NormalizedEntryType, plain_text_processor::PlainTextLogProcessor, + stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider, + }, + stdout_dup::{self, StdoutAppender}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] +pub struct Copilot { + #[serde(default)] + pub append_prompt: AppendPrompt, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_all_tools: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_tool: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deny_tool: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub add_dir: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_mcp_server: Option>, + #[serde(flatten)] + pub cmd: CmdOverrides, +} + +impl Copilot { + fn build_command_builder(&self, log_dir: &str) -> CommandBuilder { + let mut builder = CommandBuilder::new("npx -y @github/copilot@0.0.332").params([ + "--no-color", + "--log-level", + "debug", + "--log-dir", + log_dir, + ]); + + if self.allow_all_tools.unwrap_or(false) { + builder = builder.extend_params(["--allow-all-tools"]); + } + + if let Some(model) = &self.model { + builder = builder.extend_params(["--model", model]); + } + + if let Some(tool) = &self.allow_tool { + builder = builder.extend_params(["--allow-tool", tool]); + } + + if let Some(tool) = &self.deny_tool { + builder = builder.extend_params(["--deny-tool", tool]); + } + + if let Some(dirs) = &self.add_dir { + for dir in dirs { + builder = builder.extend_params(["--add-dir", dir]); + } + } + + if let Some(servers) = &self.disable_mcp_server { + for server in servers { + builder = builder.extend_params(["--disable-mcp-server", server]); + } + } + + apply_overrides(builder, &self.cmd) + } +} + +#[async_trait] +impl StandardCodingAgentExecutor for Copilot { + async fn spawn(&self, current_dir: &Path, prompt: &str) -> Result { + let (shell_cmd, shell_arg) = get_shell_command(); + let log_dir = Self::create_temp_log_dir(current_dir).await?; + let copilot_command = self + .build_command_builder(&log_dir.to_string_lossy()) + .build_initial(); + + let combined_prompt = self.append_prompt.combine_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(copilot_command) + .env("NODE_NO_WARNINGS", "1"); + + let mut child = command.group_spawn()?; + + // Write prompt to stdin + if let Some(mut stdin) = child.inner().stdin.take() { + stdin.write_all(combined_prompt.as_bytes()).await?; + stdin.shutdown().await?; + } + + let (_, appender) = stdout_dup::tee_stdout_with_appender(&mut child)?; + Self::send_session_id(log_dir, appender); + + Ok(child.into()) + } + + async fn spawn_follow_up( + &self, + current_dir: &Path, + prompt: &str, + session_id: &str, + ) -> Result { + let (shell_cmd, shell_arg) = get_shell_command(); + let log_dir = Self::create_temp_log_dir(current_dir).await?; + let copilot_command = self + .build_command_builder(&log_dir.to_string_lossy()) + .build_follow_up(&["--resume".to_string(), session_id.to_string()]); + + let combined_prompt = self.append_prompt.combine_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(copilot_command) + .env("NODE_NO_WARNINGS", "1"); + + let mut child = command.group_spawn()?; + + // Write comprehensive prompt to stdin + if let Some(mut stdin) = child.inner().stdin.take() { + stdin.write_all(combined_prompt.as_bytes()).await?; + stdin.shutdown().await?; + } + + let (_, appender) = stdout_dup::tee_stdout_with_appender(&mut child)?; + Self::send_session_id(log_dir, appender); + + Ok(child.into()) + } + + /// Parses both stderr and stdout logs for Copilot executor using PlainTextLogProcessor. + /// + /// Each entry is converted into an `AssistantMessage` or `ErrorMessage` and emitted as patches. + fn normalize_logs(&self, msg_store: Arc, _worktree_path: &Path) { + let entry_index_counter = EntryIndexProvider::start_from(&msg_store); + normalize_stderr_logs(msg_store.clone(), entry_index_counter.clone()); + + // Normalize Agent logs + tokio::spawn(async move { + let mut stdout_lines = msg_store.stdout_lines_stream(); + + let mut processor = Self::create_simple_stdout_normalizer(entry_index_counter); + + while let Some(Ok(line)) = stdout_lines.next().await { + if let Some(session_id) = line.strip_prefix(Self::SESSION_PREFIX) { + msg_store.push_session_id(session_id.trim().to_string()); + continue; + } + + for patch in processor.process(line + "\n") { + msg_store.push_patch(patch); + } + } + }); + } + + // MCP configuration methods + fn default_mcp_config_path(&self) -> Option { + dirs::home_dir().map(|home| home.join(".copilot").join("mcp-config.json")) + } +} + +impl Copilot { + fn create_simple_stdout_normalizer( + index_provider: EntryIndexProvider, + ) -> PlainTextLogProcessor { + PlainTextLogProcessor::builder() + .normalized_entry_producer(Box::new(|content: String| NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::AssistantMessage, + content, + metadata: None, + })) + .transform_lines(Box::new(|lines| { + lines.iter_mut().for_each(|line| { + *line = strip_ansi_escapes::strip_str(&line); + }) + })) + .index_provider(index_provider) + .build() + } + + async fn create_temp_log_dir(current_dir: &Path) -> Result { + let base_log_dir = get_vibe_kanban_temp_dir().join("copilot_logs"); + fs::create_dir_all(&base_log_dir) + .await + .map_err(ExecutorError::Io)?; + + let run_log_dir = base_log_dir + .join(current_dir.file_name().unwrap_or_default()) + .join(Uuid::new_v4().to_string()); + fs::create_dir_all(&run_log_dir) + .await + .map_err(ExecutorError::Io)?; + + Ok(run_log_dir) + } + + // Scan the log directory for a file named `.log` and extract the UUID as session ID. + async fn watch_session_id(log_dir_path: PathBuf) -> Result { + let mut ticker = interval(Duration::from_millis(200)); + + timeout(Duration::from_secs(600), async { + loop { + if let Ok(mut rd) = fs::read_dir(&log_dir_path).await { + while let Ok(Some(e)) = rd.next_entry().await { + if let Some(name) = + e.file_name().to_str().and_then(|n| n.strip_suffix(".log")) + && Uuid::parse_str(name).is_ok() + { + return name.to_string(); + } + } + } + ticker.tick().await; + } + }) + .await + .map_err(|_| format!("No .log found in {log_dir_path:?}")) + } + + const SESSION_PREFIX: &'static str = "[copilot-session] "; + + // Find session id and write it to stdout prefixed + fn send_session_id(log_dir_path: PathBuf, stdout_appender: StdoutAppender) { + tokio::spawn(async move { + match Self::watch_session_id(log_dir_path).await { + Ok(session_id) => { + let session_line = format!("{}{}\n", Self::SESSION_PREFIX, session_id); + stdout_appender.append_line(&session_line); + } + Err(e) => { + tracing::error!("Failed to find session ID: {}", e); + } + } + }); + } +} diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index 3e99db46..6a612ab0 100644 --- a/crates/executors/src/executors/mod.rs +++ b/crates/executors/src/executors/mod.rs @@ -14,8 +14,8 @@ use workspace_utils::msg_store::MsgStore; use crate::{ executors::{ - amp::Amp, claude::ClaudeCode, codex::Codex, cursor::Cursor, gemini::Gemini, - opencode::Opencode, qwen::QwenCode, + amp::Amp, claude::ClaudeCode, codex::Codex, copilot::Copilot, cursor::Cursor, + gemini::Gemini, opencode::Opencode, qwen::QwenCode, }, mcp_config::McpConfig, }; @@ -24,6 +24,7 @@ pub mod acp; pub mod amp; pub mod claude; pub mod codex; +pub mod copilot; pub mod cursor; pub mod gemini; pub mod opencode; @@ -76,6 +77,7 @@ pub enum CodingAgent { Opencode, Cursor, QwenCode, + Copilot, } impl CodingAgent { @@ -128,7 +130,7 @@ impl CodingAgent { Self::Codex(_) => vec![BaseAgentCapability::SessionFork], Self::Gemini(_) => vec![BaseAgentCapability::SessionFork], Self::QwenCode(_) => vec![BaseAgentCapability::SessionFork], - Self::Opencode(_) | Self::Cursor(_) => vec![], + Self::Opencode(_) | Self::Cursor(_) | Self::Copilot(_) => vec![], } } } diff --git a/crates/executors/src/mcp_config.rs b/crates/executors/src/mcp_config.rs index 745f45a8..b8352f10 100644 --- a/crates/executors/src/mcp_config.rs +++ b/crates/executors/src/mcp_config.rs @@ -249,12 +249,27 @@ fn adapt_opencode(servers: ServerMap, meta: Option) -> Value { attach_meta(servers, meta) } +fn adapt_copilot(mut servers: ServerMap, meta: Option) -> Value { + for (_, value) in servers.iter_mut() { + if let Value::Object(s) = value + && !s.contains_key("tools") + { + s.insert( + "tools".to_string(), + Value::Array(vec![Value::String("*".to_string())]), + ); + } + } + attach_meta(servers, meta) +} + enum Adapter { Passthrough, Gemini, Cursor, Codex, Opencode, + Copilot, } fn apply_adapter(adapter: Adapter, canonical: Value) -> Value { @@ -269,6 +284,7 @@ fn apply_adapter(adapter: Adapter, canonical: Value) -> Value { Adapter::Cursor => adapt_cursor(servers_only, meta), Adapter::Codex => adapt_codex(servers_only, meta), Adapter::Opencode => adapt_opencode(servers_only, meta), + Adapter::Copilot => adapt_copilot(servers_only, meta), } } @@ -282,6 +298,7 @@ impl CodingAgent { CodingAgent::Cursor(_) => Cursor, CodingAgent::Codex(_) => Codex, CodingAgent::Opencode(_) => Opencode, + CodingAgent::Copilot(..) => Copilot, }; let canonical = PRECONFIGURED_MCP_SERVERS.clone(); diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 92aed015..a4f7db80 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -81,6 +81,7 @@ fn generate_types_content() -> String { executors::executors::codex::ReasoningSummary::decl(), executors::executors::codex::ReasoningSummaryFormat::decl(), executors::executors::cursor::Cursor::decl(), + executors::executors::copilot::Copilot::decl(), executors::executors::opencode::Opencode::decl(), executors::executors::qwen::QwenCode::decl(), executors::executors::AppendPrompt::decl(), @@ -187,6 +188,10 @@ fn generate_schemas() -> Result, serde_json::Error "qwen_code", generate_json_schema::()?, ), + ( + "copilot", + generate_json_schema::()?, + ), ]); println!( "✅ JSON schemas generated. {} schemas created.", diff --git a/frontend/src/components/ExecutorConfigForm.tsx b/frontend/src/components/ExecutorConfigForm.tsx index 3d0d8055..5bf214f6 100644 --- a/frontend/src/components/ExecutorConfigForm.tsx +++ b/frontend/src/components/ExecutorConfigForm.tsx @@ -16,6 +16,7 @@ type ExecutorType = | 'GEMINI' | 'CODEX' | 'CURSOR' + | 'COPILOT' | 'OPENCODE' | 'QWEN_CODE'; diff --git a/shared/schemas/copilot.json b/shared/schemas/copilot.json new file mode 100644 index 00000000..48e75720 --- /dev/null +++ b/shared/schemas/copilot.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "append_prompt": { + "title": "Append Prompt", + "description": "Extra text appended to the prompt", + "type": [ + "string", + "null" + ], + "format": "textarea", + "default": null + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "allow_all_tools": { + "type": [ + "boolean", + "null" + ] + }, + "allow_tool": { + "type": [ + "string", + "null" + ] + }, + "deny_tool": { + "type": [ + "string", + "null" + ] + }, + "add_dir": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "disable_mcp_server": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "base_command_override": { + "title": "Base Command Override", + "description": "Override the base command with a custom command", + "type": [ + "string", + "null" + ] + }, + "additional_params": { + "title": "Additional Parameters", + "description": "Additional parameters to append to the base command", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "type": "object" +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts index 9e1ff0b6..7b1c83b7 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -30,9 +30,9 @@ export type ScriptRequest = { script: string, language: ScriptRequestLanguage, c export type ScriptRequestLanguage = "Bash"; -export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE", CURSOR = "CURSOR", QWEN_CODE = "QWEN_CODE" } +export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE", CURSOR = "CURSOR", QWEN_CODE = "QWEN_CODE", COPILOT = "COPILOT" } -export type CodingAgent = { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode }; +export type CodingAgent = { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot }; export type TaskTemplate = { id: string, project_id: string | null, title: string, description: string | null, template_name: string, created_at: string, updated_at: string, }; @@ -150,7 +150,7 @@ executor: BaseCodingAgent, */ variant: string | null, }; -export type ExecutorConfig = { [key in string]?: { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } }; +export type ExecutorConfig = { [key in string]?: { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot } }; export type BaseAgentCapability = "SESSION_FORK"; @@ -174,6 +174,8 @@ export type ReasoningSummaryFormat = "none" | "experimental"; export type Cursor = { append_prompt: AppendPrompt, force?: boolean | null, model?: string | null, base_command_override?: string | null, additional_params?: Array | null, }; +export type Copilot = { append_prompt: AppendPrompt, model?: string | null, allow_all_tools?: boolean | null, allow_tool?: string | null, deny_tool?: string | null, add_dir?: Array | null, disable_mcp_server?: Array | null, base_command_override?: string | null, additional_params?: Array | null, }; + export type Opencode = { append_prompt: AppendPrompt, model?: string | null, agent?: string | null, base_command_override?: string | null, additional_params?: Array | null, }; export type QwenCode = { append_prompt: AppendPrompt, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, };