Skip to main content

zag_agent/providers/
copilot.rs

1// provider-updated: 2026-04-05
2use crate::agent::{Agent, ModelSize};
3use crate::output::AgentOutput;
4use crate::sandbox::SandboxConfig;
5use crate::session_log::{
6    BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
7    LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
8};
9use anyhow::{Context, Result};
10
11/// Classify a Copilot tool name into a normalized ToolKind.
12fn tool_kind_from_name(name: &str) -> ToolKind {
13    match name {
14        "bash" | "shell" => ToolKind::Shell,
15        "view" | "read" | "cat" => ToolKind::FileRead,
16        "write" => ToolKind::FileWrite,
17        "edit" | "insert" | "replace" => ToolKind::FileEdit,
18        "grep" | "glob" | "find" | "search" => ToolKind::Search,
19        _ => ToolKind::Other,
20    }
21}
22use async_trait::async_trait;
23use log::info;
24use std::collections::HashSet;
25use std::io::{BufRead, BufReader, Seek, SeekFrom};
26use std::path::{Path, PathBuf};
27use std::process::Stdio;
28use tokio::fs;
29use tokio::process::Command;
30
31/// Return the Copilot session-state directory: `~/.copilot/session-state/`.
32pub fn session_state_dir() -> std::path::PathBuf {
33    dirs::home_dir()
34        .unwrap_or_else(|| std::path::PathBuf::from("."))
35        .join(".copilot/session-state")
36}
37
38pub const DEFAULT_MODEL: &str = "claude-sonnet-4.6";
39
40pub const AVAILABLE_MODELS: &[&str] = &[
41    "claude-sonnet-4.6",
42    "claude-haiku-4.5",
43    "claude-opus-4.6",
44    "claude-sonnet-4.5",
45    "claude-opus-4.5",
46    "gpt-5.4",
47    "gpt-5.4-mini",
48    "gpt-5.3-codex",
49    "gpt-5.2-codex",
50    "gpt-5.2",
51    "gpt-5.1-codex-max",
52    "gpt-5.1-codex",
53    "gpt-5.1",
54    "gpt-5",
55    "gpt-5.1-codex-mini",
56    "gpt-5-mini",
57    "gpt-4.1",
58    "gemini-3.1-pro-preview",
59    "gemini-3-pro-preview",
60];
61
62pub struct Copilot {
63    system_prompt: String,
64    model: String,
65    root: Option<String>,
66    skip_permissions: bool,
67    output_format: Option<String>,
68    add_dirs: Vec<String>,
69    capture_output: bool,
70    sandbox: Option<SandboxConfig>,
71    max_turns: Option<u32>,
72    env_vars: Vec<(String, String)>,
73}
74
75pub struct CopilotLiveLogAdapter {
76    ctx: LiveLogContext,
77    session_path: Option<PathBuf>,
78    offset: u64,
79    seen_event_ids: HashSet<String>,
80}
81
82pub struct CopilotHistoricalLogAdapter;
83
84impl Copilot {
85    pub fn new() -> Self {
86        Self {
87            system_prompt: String::new(),
88            model: DEFAULT_MODEL.to_string(),
89            root: None,
90            skip_permissions: false,
91            output_format: None,
92            add_dirs: Vec::new(),
93            capture_output: false,
94            sandbox: None,
95            max_turns: None,
96            env_vars: Vec::new(),
97        }
98    }
99
100    fn get_base_path(&self) -> &Path {
101        self.root.as_ref().map(Path::new).unwrap_or(Path::new("."))
102    }
103
104    async fn write_instructions_file(&self) -> Result<()> {
105        let base = self.get_base_path();
106        log::debug!("Writing Copilot instructions file to {}", base.display());
107        let instructions_dir = base.join(".github/instructions/agent");
108        fs::create_dir_all(&instructions_dir).await?;
109        fs::write(
110            instructions_dir.join("agent.instructions.md"),
111            &self.system_prompt,
112        )
113        .await?;
114        Ok(())
115    }
116
117    /// Build the argument list for a run/exec invocation.
118    fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
119        let mut args = Vec::new();
120
121        // In non-interactive mode, --allow-all is required
122        if !interactive || self.skip_permissions {
123            args.push("--allow-all".to_string());
124        }
125
126        if !self.model.is_empty() {
127            args.extend(["--model".to_string(), self.model.clone()]);
128        }
129
130        for dir in &self.add_dirs {
131            args.extend(["--add-dir".to_string(), dir.clone()]);
132        }
133
134        if let Some(turns) = self.max_turns {
135            args.extend(["--max-turns".to_string(), turns.to_string()]);
136        }
137
138        match (interactive, prompt) {
139            (true, Some(p)) => args.extend(["-i".to_string(), p.to_string()]),
140            (false, Some(p)) => args.extend(["-p".to_string(), p.to_string()]),
141            _ => {}
142        }
143
144        args
145    }
146
147    /// Create a `Command` either directly or wrapped in sandbox.
148    fn make_command(&self, agent_args: Vec<String>) -> Command {
149        if let Some(ref sb) = self.sandbox {
150            let std_cmd = crate::sandbox::build_sandbox_command(sb, agent_args);
151            Command::from(std_cmd)
152        } else {
153            let mut cmd = Command::new("copilot");
154            if let Some(ref root) = self.root {
155                cmd.current_dir(root);
156            }
157            cmd.args(&agent_args);
158            for (key, value) in &self.env_vars {
159                cmd.env(key, value);
160            }
161            cmd
162        }
163    }
164
165    async fn execute(
166        &self,
167        interactive: bool,
168        prompt: Option<&str>,
169    ) -> Result<Option<AgentOutput>> {
170        // Output format flags are not supported by Copilot
171        if self.output_format.is_some() {
172            anyhow::bail!(
173                "Copilot does not support the --output flag. Remove the flag and try again."
174            );
175        }
176
177        if !self.system_prompt.is_empty() {
178            log::debug!(
179                "Copilot system prompt (written to instructions): {}",
180                self.system_prompt
181            );
182            self.write_instructions_file().await?;
183        }
184
185        let agent_args = self.build_run_args(interactive, prompt);
186        log::debug!("Copilot command: copilot {}", agent_args.join(" "));
187        if let Some(p) = prompt {
188            log::debug!("Copilot user prompt: {}", p);
189        }
190        let mut cmd = self.make_command(agent_args);
191
192        if interactive {
193            cmd.stdin(Stdio::inherit())
194                .stdout(Stdio::inherit())
195                .stderr(Stdio::inherit());
196            let status = cmd
197                .status()
198                .await
199                .context("Failed to execute 'copilot' CLI. Is it installed and in PATH?")?;
200            if !status.success() {
201                anyhow::bail!("Copilot command failed with status: {}", status);
202            }
203            Ok(None)
204        } else if self.capture_output {
205            let text = crate::process::run_captured(&mut cmd, "Copilot").await?;
206            log::debug!("Copilot raw response ({} bytes): {}", text.len(), text);
207            Ok(Some(AgentOutput::from_text("copilot", &text)))
208        } else {
209            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
210            crate::process::run_with_captured_stderr(&mut cmd).await?;
211            Ok(None)
212        }
213    }
214}
215
216#[cfg(test)]
217#[path = "copilot_tests.rs"]
218mod tests;
219
220impl Default for Copilot {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226impl CopilotLiveLogAdapter {
227    pub fn new(ctx: LiveLogContext) -> Self {
228        Self {
229            ctx,
230            session_path: None,
231            offset: 0,
232            seen_event_ids: HashSet::new(),
233        }
234    }
235
236    fn discover_session_path(&self) -> Option<PathBuf> {
237        let base = copilot_session_state_dir();
238        if let Some(session_id) = &self.ctx.provider_session_id {
239            let candidate = base.join(session_id).join("events.jsonl");
240            if candidate.exists() {
241                return Some(candidate);
242            }
243        }
244
245        let started_at = system_time_from_utc(self.ctx.started_at);
246        let workspace = self
247            .ctx
248            .workspace_path
249            .as_deref()
250            .or(self.ctx.root.as_deref());
251        let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
252        let entries = std::fs::read_dir(base).ok()?;
253        for entry in entries.flatten() {
254            let path = entry.path().join("events.jsonl");
255            if !path.exists() {
256                continue;
257            }
258            let modified = entry
259                .metadata()
260                .ok()
261                .and_then(|metadata| metadata.modified().ok())
262                .or_else(|| {
263                    std::fs::metadata(&path)
264                        .ok()
265                        .and_then(|metadata| metadata.modified().ok())
266                })?;
267            if modified < started_at {
268                continue;
269            }
270            if let Some(workspace) = workspace
271                && !copilot_session_matches_workspace(&entry.path(), workspace)
272            {
273                continue;
274            }
275            if best
276                .as_ref()
277                .map(|(current, _)| modified > *current)
278                .unwrap_or(true)
279            {
280                best = Some((modified, path));
281            }
282        }
283        best.map(|(_, path)| path)
284    }
285}
286
287#[async_trait]
288impl LiveLogAdapter for CopilotLiveLogAdapter {
289    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
290        if self.session_path.is_none() {
291            self.session_path = self.discover_session_path();
292            if let Some(path) = &self.session_path {
293                writer.add_source_path(path.to_string_lossy().to_string())?;
294                let metadata_path = path.with_file_name("vscode.metadata.json");
295                if metadata_path.exists() {
296                    writer.add_source_path(metadata_path.to_string_lossy().to_string())?;
297                }
298                let workspace_path = path.with_file_name("workspace.yaml");
299                if workspace_path.exists() {
300                    writer.add_source_path(workspace_path.to_string_lossy().to_string())?;
301                }
302            }
303        }
304
305        let Some(path) = self.session_path.as_ref() else {
306            return Ok(());
307        };
308
309        let mut file = std::fs::File::open(path)
310            .with_context(|| format!("Failed to open {}", path.display()))?;
311        file.seek(SeekFrom::Start(self.offset))?;
312        let mut reader = BufReader::new(file);
313        let mut line = String::new();
314
315        while reader.read_line(&mut line)? > 0 {
316            self.offset += line.len() as u64;
317            let trimmed = line.trim();
318            if trimmed.is_empty() {
319                line.clear();
320                continue;
321            }
322            let Some(parsed) = parse_copilot_event_line(trimmed, &mut self.seen_event_ids) else {
323                line.clear();
324                continue;
325            };
326            if parsed.parse_failed {
327                writer.emit(
328                    LogSourceKind::ProviderFile,
329                    LogEventKind::ParseWarning {
330                        message: "Failed to parse Copilot event line".to_string(),
331                        raw: Some(trimmed.to_string()),
332                    },
333                )?;
334                line.clear();
335                continue;
336            }
337            if let Some(session_id) = parsed.provider_session_id {
338                writer.set_provider_session_id(Some(session_id))?;
339            }
340            for event in parsed.events {
341                writer.emit(LogSourceKind::ProviderFile, event)?;
342            }
343            line.clear();
344        }
345
346        Ok(())
347    }
348}
349
350impl HistoricalLogAdapter for CopilotHistoricalLogAdapter {
351    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
352        let base = copilot_session_state_dir();
353        let entries = match std::fs::read_dir(&base) {
354            Ok(entries) => entries,
355            Err(_) => return Ok(Vec::new()),
356        };
357        let mut sessions = Vec::new();
358        for entry in entries.flatten() {
359            let session_dir = entry.path();
360            if !session_dir.is_dir() {
361                continue;
362            }
363            let events_path = session_dir.join("events.jsonl");
364            if !events_path.exists() {
365                continue;
366            }
367            info!("Scanning Copilot history: {}", events_path.display());
368            let file = std::fs::File::open(&events_path)
369                .with_context(|| format!("Failed to open {}", events_path.display()))?;
370            let reader = BufReader::new(file);
371            let mut seen_event_ids = HashSet::new();
372            let mut events = Vec::new();
373            let mut provider_session_id = None;
374            let mut model = None;
375            let mut workspace_path = read_copilot_workspace_path(&session_dir);
376
377            for line in reader.lines() {
378                let line = line?;
379                let trimmed = line.trim();
380                if trimmed.is_empty() {
381                    continue;
382                }
383                let Some(parsed) = parse_copilot_event_line(trimmed, &mut seen_event_ids) else {
384                    continue;
385                };
386                if parsed.parse_failed {
387                    events.push((
388                        LogSourceKind::Backfill,
389                        LogEventKind::ParseWarning {
390                            message: "Failed to parse Copilot event line".to_string(),
391                            raw: Some(trimmed.to_string()),
392                        },
393                    ));
394                    continue;
395                }
396                if provider_session_id.is_none() {
397                    provider_session_id = parsed.provider_session_id;
398                }
399                if model.is_none() {
400                    model = parsed.model;
401                }
402                if workspace_path.is_none() {
403                    workspace_path = parsed.workspace_path;
404                }
405                for event in parsed.events {
406                    events.push((LogSourceKind::Backfill, event));
407                }
408            }
409
410            let session_id = provider_session_id
411                .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
412            let mut source_paths = vec![events_path.to_string_lossy().to_string()];
413            let metadata_path = session_dir.join("vscode.metadata.json");
414            if metadata_path.exists() {
415                source_paths.push(metadata_path.to_string_lossy().to_string());
416            }
417            let workspace_yaml = session_dir.join("workspace.yaml");
418            if workspace_yaml.exists() {
419                source_paths.push(workspace_yaml.to_string_lossy().to_string());
420            }
421            sessions.push(BackfilledSession {
422                metadata: SessionLogMetadata {
423                    provider: "copilot".to_string(),
424                    wrapper_session_id: session_id.clone(),
425                    provider_session_id: Some(session_id),
426                    workspace_path,
427                    command: "backfill".to_string(),
428                    model,
429                    resumed: false,
430                    backfilled: true,
431                },
432                completeness: LogCompleteness::Full,
433                source_paths,
434                events,
435            });
436        }
437        Ok(sessions)
438    }
439}
440
441pub(crate) struct ParsedCopilotEvent {
442    pub(crate) provider_session_id: Option<String>,
443    pub(crate) model: Option<String>,
444    pub(crate) workspace_path: Option<String>,
445    pub(crate) events: Vec<LogEventKind>,
446    pub(crate) parse_failed: bool,
447}
448
449pub(crate) fn parse_copilot_event_line(
450    line: &str,
451    seen_event_ids: &mut HashSet<String>,
452) -> Option<ParsedCopilotEvent> {
453    let value: serde_json::Value = match serde_json::from_str(line) {
454        Ok(value) => value,
455        Err(_) => {
456            return Some(ParsedCopilotEvent {
457                provider_session_id: None,
458                model: None,
459                workspace_path: None,
460                events: Vec::new(),
461                parse_failed: true,
462            });
463        }
464    };
465
466    let event_id = value
467        .get("id")
468        .and_then(|value| value.as_str())
469        .unwrap_or_default();
470    if !event_id.is_empty() && !seen_event_ids.insert(event_id.to_string()) {
471        return None;
472    }
473
474    let event_type = value
475        .get("type")
476        .and_then(|value| value.as_str())
477        .unwrap_or_default();
478    let data = value
479        .get("data")
480        .cloned()
481        .unwrap_or(serde_json::Value::Null);
482    let provider_session_id = value
483        .get("data")
484        .and_then(|value| value.get("sessionId"))
485        .and_then(|value| value.as_str())
486        .map(str::to_string);
487    let model = value
488        .get("data")
489        .and_then(|value| value.get("selectedModel"))
490        .and_then(|value| value.as_str())
491        .map(str::to_string);
492    let workspace_path = value
493        .get("data")
494        .and_then(|value| value.get("context"))
495        .and_then(|value| value.get("cwd").or_else(|| value.get("gitRoot")))
496        .and_then(|value| value.as_str())
497        .map(str::to_string);
498    let mut events = Vec::new();
499
500    match event_type {
501        "session.start" => events.push(LogEventKind::ProviderStatus {
502            message: "Copilot session started".to_string(),
503            data: Some(data),
504        }),
505        "session.model_change" => events.push(LogEventKind::ProviderStatus {
506            message: "Copilot model changed".to_string(),
507            data: Some(data),
508        }),
509        "session.info" => events.push(LogEventKind::ProviderStatus {
510            message: data
511                .get("message")
512                .and_then(|value| value.as_str())
513                .unwrap_or("Copilot session info")
514                .to_string(),
515            data: Some(data),
516        }),
517        "session.truncation" => events.push(LogEventKind::ProviderStatus {
518            message: "Copilot session truncation".to_string(),
519            data: Some(data),
520        }),
521        "user.message" => events.push(LogEventKind::UserMessage {
522            role: "user".to_string(),
523            content: data
524                .get("content")
525                .or_else(|| data.get("transformedContent"))
526                .and_then(|value| value.as_str())
527                .unwrap_or_default()
528                .to_string(),
529            message_id: value
530                .get("id")
531                .and_then(|value| value.as_str())
532                .map(str::to_string),
533        }),
534        "assistant.turn_start" => events.push(LogEventKind::ProviderStatus {
535            message: "Copilot assistant turn started".to_string(),
536            data: Some(data),
537        }),
538        "assistant.turn_end" => events.push(LogEventKind::ProviderStatus {
539            message: "Copilot assistant turn ended".to_string(),
540            data: Some(data),
541        }),
542        "assistant.message" => {
543            let message_id = data
544                .get("messageId")
545                .and_then(|value| value.as_str())
546                .map(str::to_string);
547            let content = data
548                .get("content")
549                .and_then(|value| value.as_str())
550                .unwrap_or_default()
551                .to_string();
552            if !content.is_empty() {
553                events.push(LogEventKind::AssistantMessage {
554                    content,
555                    message_id: message_id.clone(),
556                });
557            }
558            if let Some(tool_requests) = data.get("toolRequests").and_then(|value| value.as_array())
559            {
560                for request in tool_requests {
561                    let name = request
562                        .get("name")
563                        .and_then(|value| value.as_str())
564                        .unwrap_or_default();
565                    events.push(LogEventKind::ToolCall {
566                        tool_kind: Some(tool_kind_from_name(name)),
567                        tool_name: name.to_string(),
568                        tool_id: request
569                            .get("toolCallId")
570                            .and_then(|value| value.as_str())
571                            .map(str::to_string),
572                        input: request.get("arguments").cloned(),
573                    });
574                }
575            }
576        }
577        "assistant.reasoning" => {
578            let content = data
579                .get("content")
580                .and_then(|value| value.as_str())
581                .unwrap_or_default()
582                .to_string();
583            if !content.is_empty() {
584                events.push(LogEventKind::Reasoning {
585                    content,
586                    message_id: data
587                        .get("reasoningId")
588                        .and_then(|value| value.as_str())
589                        .map(str::to_string),
590                });
591            }
592        }
593        "tool.execution_start" => {
594            let name = data
595                .get("toolName")
596                .and_then(|value| value.as_str())
597                .unwrap_or_default();
598            events.push(LogEventKind::ToolCall {
599                tool_kind: Some(tool_kind_from_name(name)),
600                tool_name: name.to_string(),
601                tool_id: data
602                    .get("toolCallId")
603                    .and_then(|value| value.as_str())
604                    .map(str::to_string),
605                input: data.get("arguments").cloned(),
606            });
607        }
608        "tool.execution_complete" => {
609            let name = data.get("toolName").and_then(|value| value.as_str());
610            events.push(LogEventKind::ToolResult {
611                tool_kind: name.map(tool_kind_from_name),
612                tool_name: name.map(str::to_string),
613                tool_id: data
614                    .get("toolCallId")
615                    .and_then(|value| value.as_str())
616                    .map(str::to_string),
617                success: data.get("success").and_then(|value| value.as_bool()),
618                output: data
619                    .get("result")
620                    .and_then(|value| value.get("content"))
621                    .and_then(|value| value.as_str())
622                    .map(str::to_string),
623                error: data
624                    .get("result")
625                    .and_then(|value| value.get("error"))
626                    .and_then(|value| value.as_str())
627                    .map(str::to_string),
628                data: Some(data),
629            });
630        }
631        _ => events.push(LogEventKind::ProviderStatus {
632            message: format!("Copilot event: {event_type}"),
633            data: Some(data),
634        }),
635    }
636
637    Some(ParsedCopilotEvent {
638        provider_session_id,
639        model,
640        workspace_path,
641        events,
642        parse_failed: false,
643    })
644}
645
646fn copilot_session_state_dir() -> PathBuf {
647    session_state_dir()
648}
649
650fn read_copilot_workspace_path(session_dir: &Path) -> Option<String> {
651    let metadata_path = session_dir.join("vscode.metadata.json");
652    if let Ok(content) = std::fs::read_to_string(&metadata_path)
653        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
654    {
655        if let Some(path) = value
656            .get("cwd")
657            .or_else(|| value.get("workspacePath"))
658            .or_else(|| value.get("gitRoot"))
659            .and_then(|value| value.as_str())
660        {
661            return Some(path.to_string());
662        }
663    }
664    let workspace_yaml = session_dir.join("workspace.yaml");
665    if let Ok(content) = std::fs::read_to_string(workspace_yaml) {
666        for line in content.lines() {
667            let trimmed = line.trim();
668            if let Some(rest) = trimmed
669                .strip_prefix("cwd:")
670                .or_else(|| trimmed.strip_prefix("workspace:"))
671                .or_else(|| trimmed.strip_prefix("path:"))
672            {
673                return Some(rest.trim().trim_matches('"').to_string());
674            }
675        }
676    }
677    None
678}
679
680fn copilot_session_matches_workspace(session_dir: &Path, workspace: &str) -> bool {
681    if let Some(candidate) = read_copilot_workspace_path(session_dir) {
682        return candidate == workspace;
683    }
684
685    let events_path = session_dir.join("events.jsonl");
686    let file = match std::fs::File::open(events_path) {
687        Ok(file) => file,
688        Err(_) => return false,
689    };
690    let reader = BufReader::new(file);
691    for line in reader.lines().map_while(Result::ok).take(8) {
692        let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
693            continue;
694        };
695        let Some(data) = value.get("data") else {
696            continue;
697        };
698        let candidate = data
699            .get("context")
700            .and_then(|context| context.get("cwd").or_else(|| context.get("gitRoot")))
701            .and_then(|value| value.as_str());
702        if candidate == Some(workspace) {
703            return true;
704        }
705    }
706    false
707}
708
709fn system_time_from_utc(value: chrono::DateTime<chrono::Utc>) -> std::time::SystemTime {
710    std::time::SystemTime::UNIX_EPOCH
711        + std::time::Duration::from_secs(value.timestamp().max(0) as u64)
712}
713
714#[async_trait]
715impl Agent for Copilot {
716    fn name(&self) -> &str {
717        "copilot"
718    }
719
720    fn default_model() -> &'static str {
721        DEFAULT_MODEL
722    }
723
724    fn model_for_size(size: ModelSize) -> &'static str {
725        match size {
726            ModelSize::Small => "claude-haiku-4.5",
727            ModelSize::Medium => "claude-sonnet-4.6",
728            ModelSize::Large => "claude-opus-4.6",
729        }
730    }
731
732    fn available_models() -> &'static [&'static str] {
733        AVAILABLE_MODELS
734    }
735
736    fn system_prompt(&self) -> &str {
737        &self.system_prompt
738    }
739
740    fn set_system_prompt(&mut self, prompt: String) {
741        self.system_prompt = prompt;
742    }
743
744    fn get_model(&self) -> &str {
745        &self.model
746    }
747
748    fn set_model(&mut self, model: String) {
749        self.model = model;
750    }
751
752    fn set_root(&mut self, root: String) {
753        self.root = Some(root);
754    }
755
756    fn set_skip_permissions(&mut self, skip: bool) {
757        self.skip_permissions = skip;
758    }
759
760    fn set_output_format(&mut self, format: Option<String>) {
761        self.output_format = format;
762    }
763
764    fn set_add_dirs(&mut self, dirs: Vec<String>) {
765        self.add_dirs = dirs;
766    }
767
768    fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
769        self.env_vars = vars;
770    }
771
772    fn set_capture_output(&mut self, capture: bool) {
773        self.capture_output = capture;
774    }
775
776    fn set_sandbox(&mut self, config: SandboxConfig) {
777        self.sandbox = Some(config);
778    }
779
780    fn set_max_turns(&mut self, turns: u32) {
781        self.max_turns = Some(turns);
782    }
783
784    fn as_any_ref(&self) -> &dyn std::any::Any {
785        self
786    }
787
788    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
789        self
790    }
791
792    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
793        self.execute(false, prompt).await
794    }
795
796    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
797        self.execute(true, prompt).await?;
798        Ok(())
799    }
800
801    async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
802        let mut args = if let Some(session_id) = session_id {
803            vec!["--resume".to_string(), session_id.to_string()]
804        } else if last {
805            vec!["--continue".to_string()]
806        } else {
807            vec!["--resume".to_string()]
808        };
809
810        if self.skip_permissions {
811            args.push("--allow-all".to_string());
812        }
813
814        if !self.model.is_empty() {
815            args.extend(["--model".to_string(), self.model.clone()]);
816        }
817
818        for dir in &self.add_dirs {
819            args.extend(["--add-dir".to_string(), dir.clone()]);
820        }
821
822        let mut cmd = self.make_command(args);
823
824        cmd.stdin(Stdio::inherit())
825            .stdout(Stdio::inherit())
826            .stderr(Stdio::inherit());
827
828        let status = cmd
829            .status()
830            .await
831            .context("Failed to execute 'copilot' CLI. Is it installed and in PATH?")?;
832        if !status.success() {
833            anyhow::bail!("Copilot resume failed with status: {}", status);
834        }
835        Ok(())
836    }
837
838    async fn cleanup(&self) -> Result<()> {
839        log::debug!("Cleaning up Copilot agent resources");
840        let base = self.get_base_path();
841        let instructions_file = base.join(".github/instructions/agent/agent.instructions.md");
842
843        if instructions_file.exists() {
844            fs::remove_file(&instructions_file).await?;
845        }
846
847        // Clean up empty directories
848        let agent_dir = base.join(".github/instructions/agent");
849        if agent_dir.exists()
850            && fs::read_dir(&agent_dir)
851                .await?
852                .next_entry()
853                .await?
854                .is_none()
855        {
856            fs::remove_dir(&agent_dir).await?;
857        }
858
859        let instructions_dir = base.join(".github/instructions");
860        if instructions_dir.exists()
861            && fs::read_dir(&instructions_dir)
862                .await?
863                .next_entry()
864                .await?
865                .is_none()
866        {
867            fs::remove_dir(&instructions_dir).await?;
868        }
869
870        let github_dir = base.join(".github");
871        if github_dir.exists()
872            && fs::read_dir(&github_dir)
873                .await?
874                .next_entry()
875                .await?
876                .is_none()
877        {
878            fs::remove_dir(&github_dir).await?;
879        }
880
881        Ok(())
882    }
883}