1use 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
11fn 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
31pub 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 fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
119 let mut args = Vec::new();
120
121 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 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 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 return Err(crate::process::ProcessError {
202 exit_code: status.code(),
203 stderr: String::new(),
204 agent_name: "Copilot".to_string(),
205 }
206 .into());
207 }
208 Ok(None)
209 } else if self.capture_output {
210 let text = crate::process::run_captured(&mut cmd, "Copilot").await?;
211 log::debug!("Copilot raw response ({} bytes): {}", text.len(), text);
212 Ok(Some(AgentOutput::from_text("copilot", &text)))
213 } else {
214 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
215 crate::process::run_with_captured_stderr(&mut cmd).await?;
216 Ok(None)
217 }
218 }
219}
220
221#[cfg(test)]
222#[path = "copilot_tests.rs"]
223mod tests;
224
225impl Default for Copilot {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231impl CopilotLiveLogAdapter {
232 pub fn new(ctx: LiveLogContext) -> Self {
233 Self {
234 ctx,
235 session_path: None,
236 offset: 0,
237 seen_event_ids: HashSet::new(),
238 }
239 }
240
241 fn discover_session_path(&self) -> Option<PathBuf> {
242 let base = copilot_session_state_dir();
243 if let Some(session_id) = &self.ctx.provider_session_id {
244 let candidate = base.join(session_id).join("events.jsonl");
245 if candidate.exists() {
246 return Some(candidate);
247 }
248 }
249
250 let started_at = system_time_from_utc(self.ctx.started_at);
251 let workspace = self
252 .ctx
253 .workspace_path
254 .as_deref()
255 .or(self.ctx.root.as_deref());
256 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
257 let entries = std::fs::read_dir(base).ok()?;
258 for entry in entries.flatten() {
259 let path = entry.path().join("events.jsonl");
260 if !path.exists() {
261 continue;
262 }
263 let modified = entry
264 .metadata()
265 .ok()
266 .and_then(|metadata| metadata.modified().ok())
267 .or_else(|| {
268 std::fs::metadata(&path)
269 .ok()
270 .and_then(|metadata| metadata.modified().ok())
271 })?;
272 if modified < started_at {
273 continue;
274 }
275 if let Some(workspace) = workspace
276 && !copilot_session_matches_workspace(&entry.path(), workspace)
277 {
278 continue;
279 }
280 if best
281 .as_ref()
282 .map(|(current, _)| modified > *current)
283 .unwrap_or(true)
284 {
285 best = Some((modified, path));
286 }
287 }
288 best.map(|(_, path)| path)
289 }
290}
291
292#[async_trait]
293impl LiveLogAdapter for CopilotLiveLogAdapter {
294 async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
295 if self.session_path.is_none() {
296 self.session_path = self.discover_session_path();
297 if let Some(path) = &self.session_path {
298 writer.add_source_path(path.to_string_lossy().to_string())?;
299 let metadata_path = path.with_file_name("vscode.metadata.json");
300 if metadata_path.exists() {
301 writer.add_source_path(metadata_path.to_string_lossy().to_string())?;
302 }
303 let workspace_path = path.with_file_name("workspace.yaml");
304 if workspace_path.exists() {
305 writer.add_source_path(workspace_path.to_string_lossy().to_string())?;
306 }
307 }
308 }
309
310 let Some(path) = self.session_path.as_ref() else {
311 return Ok(());
312 };
313
314 let mut file = std::fs::File::open(path)
315 .with_context(|| format!("Failed to open {}", path.display()))?;
316 file.seek(SeekFrom::Start(self.offset))?;
317 let mut reader = BufReader::new(file);
318 let mut line = String::new();
319
320 while reader.read_line(&mut line)? > 0 {
321 self.offset += line.len() as u64;
322 let trimmed = line.trim();
323 if trimmed.is_empty() {
324 line.clear();
325 continue;
326 }
327 let Some(parsed) = parse_copilot_event_line(trimmed, &mut self.seen_event_ids) else {
328 line.clear();
329 continue;
330 };
331 if parsed.parse_failed {
332 writer.emit(
333 LogSourceKind::ProviderFile,
334 LogEventKind::ParseWarning {
335 message: "Failed to parse Copilot event line".to_string(),
336 raw: Some(trimmed.to_string()),
337 },
338 )?;
339 line.clear();
340 continue;
341 }
342 if let Some(session_id) = parsed.provider_session_id {
343 writer.set_provider_session_id(Some(session_id))?;
344 }
345 for event in parsed.events {
346 writer.emit(LogSourceKind::ProviderFile, event)?;
347 }
348 line.clear();
349 }
350
351 Ok(())
352 }
353}
354
355impl HistoricalLogAdapter for CopilotHistoricalLogAdapter {
356 fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
357 let base = copilot_session_state_dir();
358 let entries = match std::fs::read_dir(&base) {
359 Ok(entries) => entries,
360 Err(_) => return Ok(Vec::new()),
361 };
362 let mut sessions = Vec::new();
363 for entry in entries.flatten() {
364 let session_dir = entry.path();
365 if !session_dir.is_dir() {
366 continue;
367 }
368 let events_path = session_dir.join("events.jsonl");
369 if !events_path.exists() {
370 continue;
371 }
372 info!("Scanning Copilot history: {}", events_path.display());
373 let file = std::fs::File::open(&events_path)
374 .with_context(|| format!("Failed to open {}", events_path.display()))?;
375 let reader = BufReader::new(file);
376 let mut seen_event_ids = HashSet::new();
377 let mut events = Vec::new();
378 let mut provider_session_id = None;
379 let mut model = None;
380 let mut workspace_path = read_copilot_workspace_path(&session_dir);
381
382 for line in reader.lines() {
383 let line = line?;
384 let trimmed = line.trim();
385 if trimmed.is_empty() {
386 continue;
387 }
388 let Some(parsed) = parse_copilot_event_line(trimmed, &mut seen_event_ids) else {
389 continue;
390 };
391 if parsed.parse_failed {
392 events.push((
393 LogSourceKind::Backfill,
394 LogEventKind::ParseWarning {
395 message: "Failed to parse Copilot event line".to_string(),
396 raw: Some(trimmed.to_string()),
397 },
398 ));
399 continue;
400 }
401 if provider_session_id.is_none() {
402 provider_session_id = parsed.provider_session_id;
403 }
404 if model.is_none() {
405 model = parsed.model;
406 }
407 if workspace_path.is_none() {
408 workspace_path = parsed.workspace_path;
409 }
410 for event in parsed.events {
411 events.push((LogSourceKind::Backfill, event));
412 }
413 }
414
415 let session_id = provider_session_id
416 .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
417 let mut source_paths = vec![events_path.to_string_lossy().to_string()];
418 let metadata_path = session_dir.join("vscode.metadata.json");
419 if metadata_path.exists() {
420 source_paths.push(metadata_path.to_string_lossy().to_string());
421 }
422 let workspace_yaml = session_dir.join("workspace.yaml");
423 if workspace_yaml.exists() {
424 source_paths.push(workspace_yaml.to_string_lossy().to_string());
425 }
426 sessions.push(BackfilledSession {
427 metadata: SessionLogMetadata {
428 provider: "copilot".to_string(),
429 wrapper_session_id: session_id.clone(),
430 provider_session_id: Some(session_id),
431 workspace_path,
432 command: "backfill".to_string(),
433 model,
434 resumed: false,
435 backfilled: true,
436 },
437 completeness: LogCompleteness::Full,
438 source_paths,
439 events,
440 });
441 }
442 Ok(sessions)
443 }
444}
445
446pub(crate) struct ParsedCopilotEvent {
447 pub(crate) provider_session_id: Option<String>,
448 pub(crate) model: Option<String>,
449 pub(crate) workspace_path: Option<String>,
450 pub(crate) events: Vec<LogEventKind>,
451 pub(crate) parse_failed: bool,
452}
453
454pub(crate) fn parse_copilot_event_line(
455 line: &str,
456 seen_event_ids: &mut HashSet<String>,
457) -> Option<ParsedCopilotEvent> {
458 let value: serde_json::Value = match serde_json::from_str(line) {
459 Ok(value) => value,
460 Err(_) => {
461 return Some(ParsedCopilotEvent {
462 provider_session_id: None,
463 model: None,
464 workspace_path: None,
465 events: Vec::new(),
466 parse_failed: true,
467 });
468 }
469 };
470
471 let event_id = value
472 .get("id")
473 .and_then(|value| value.as_str())
474 .unwrap_or_default();
475 if !event_id.is_empty() && !seen_event_ids.insert(event_id.to_string()) {
476 return None;
477 }
478
479 let event_type = value
480 .get("type")
481 .and_then(|value| value.as_str())
482 .unwrap_or_default();
483 let data = value
484 .get("data")
485 .cloned()
486 .unwrap_or(serde_json::Value::Null);
487 let provider_session_id = value
488 .get("data")
489 .and_then(|value| value.get("sessionId"))
490 .and_then(|value| value.as_str())
491 .map(str::to_string);
492 let model = value
493 .get("data")
494 .and_then(|value| value.get("selectedModel"))
495 .and_then(|value| value.as_str())
496 .map(str::to_string);
497 let workspace_path = value
498 .get("data")
499 .and_then(|value| value.get("context"))
500 .and_then(|value| value.get("cwd").or_else(|| value.get("gitRoot")))
501 .and_then(|value| value.as_str())
502 .map(str::to_string);
503 let mut events = Vec::new();
504
505 match event_type {
506 "session.start" => events.push(LogEventKind::ProviderStatus {
507 message: "Copilot session started".to_string(),
508 data: Some(data),
509 }),
510 "session.model_change" => events.push(LogEventKind::ProviderStatus {
511 message: "Copilot model changed".to_string(),
512 data: Some(data),
513 }),
514 "session.info" => events.push(LogEventKind::ProviderStatus {
515 message: data
516 .get("message")
517 .and_then(|value| value.as_str())
518 .unwrap_or("Copilot session info")
519 .to_string(),
520 data: Some(data),
521 }),
522 "session.truncation" => events.push(LogEventKind::ProviderStatus {
523 message: "Copilot session truncation".to_string(),
524 data: Some(data),
525 }),
526 "user.message" => events.push(LogEventKind::UserMessage {
527 role: "user".to_string(),
528 content: data
529 .get("content")
530 .or_else(|| data.get("transformedContent"))
531 .and_then(|value| value.as_str())
532 .unwrap_or_default()
533 .to_string(),
534 message_id: value
535 .get("id")
536 .and_then(|value| value.as_str())
537 .map(str::to_string),
538 }),
539 "assistant.turn_start" => events.push(LogEventKind::ProviderStatus {
540 message: "Copilot assistant turn started".to_string(),
541 data: Some(data),
542 }),
543 "assistant.turn_end" => events.push(LogEventKind::ProviderStatus {
544 message: "Copilot assistant turn ended".to_string(),
545 data: Some(data),
546 }),
547 "assistant.message" => {
548 let message_id = data
549 .get("messageId")
550 .and_then(|value| value.as_str())
551 .map(str::to_string);
552 let content = data
553 .get("content")
554 .and_then(|value| value.as_str())
555 .unwrap_or_default()
556 .to_string();
557 if !content.is_empty() {
558 events.push(LogEventKind::AssistantMessage {
559 content,
560 message_id: message_id.clone(),
561 });
562 }
563 if let Some(tool_requests) = data.get("toolRequests").and_then(|value| value.as_array())
564 {
565 for request in tool_requests {
566 let name = request
567 .get("name")
568 .and_then(|value| value.as_str())
569 .unwrap_or_default();
570 events.push(LogEventKind::ToolCall {
571 tool_kind: Some(tool_kind_from_name(name)),
572 tool_name: name.to_string(),
573 tool_id: request
574 .get("toolCallId")
575 .and_then(|value| value.as_str())
576 .map(str::to_string),
577 input: request.get("arguments").cloned(),
578 });
579 }
580 }
581 }
582 "assistant.reasoning" => {
583 let content = data
584 .get("content")
585 .and_then(|value| value.as_str())
586 .unwrap_or_default()
587 .to_string();
588 if !content.is_empty() {
589 events.push(LogEventKind::Reasoning {
590 content,
591 message_id: data
592 .get("reasoningId")
593 .and_then(|value| value.as_str())
594 .map(str::to_string),
595 });
596 }
597 }
598 "tool.execution_start" => {
599 let name = data
600 .get("toolName")
601 .and_then(|value| value.as_str())
602 .unwrap_or_default();
603 events.push(LogEventKind::ToolCall {
604 tool_kind: Some(tool_kind_from_name(name)),
605 tool_name: name.to_string(),
606 tool_id: data
607 .get("toolCallId")
608 .and_then(|value| value.as_str())
609 .map(str::to_string),
610 input: data.get("arguments").cloned(),
611 });
612 }
613 "tool.execution_complete" => {
614 let name = data.get("toolName").and_then(|value| value.as_str());
615 events.push(LogEventKind::ToolResult {
616 tool_kind: name.map(tool_kind_from_name),
617 tool_name: name.map(str::to_string),
618 tool_id: data
619 .get("toolCallId")
620 .and_then(|value| value.as_str())
621 .map(str::to_string),
622 success: data.get("success").and_then(|value| value.as_bool()),
623 output: data
624 .get("result")
625 .and_then(|value| value.get("content"))
626 .and_then(|value| value.as_str())
627 .map(str::to_string),
628 error: data
629 .get("result")
630 .and_then(|value| value.get("error"))
631 .and_then(|value| value.as_str())
632 .map(str::to_string),
633 data: Some(data),
634 });
635 }
636 _ => events.push(LogEventKind::ProviderStatus {
637 message: format!("Copilot event: {event_type}"),
638 data: Some(data),
639 }),
640 }
641
642 Some(ParsedCopilotEvent {
643 provider_session_id,
644 model,
645 workspace_path,
646 events,
647 parse_failed: false,
648 })
649}
650
651fn copilot_session_state_dir() -> PathBuf {
652 session_state_dir()
653}
654
655fn read_copilot_workspace_path(session_dir: &Path) -> Option<String> {
656 let metadata_path = session_dir.join("vscode.metadata.json");
657 if let Ok(content) = std::fs::read_to_string(&metadata_path)
658 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
659 {
660 if let Some(path) = value
661 .get("cwd")
662 .or_else(|| value.get("workspacePath"))
663 .or_else(|| value.get("gitRoot"))
664 .and_then(|value| value.as_str())
665 {
666 return Some(path.to_string());
667 }
668 }
669 let workspace_yaml = session_dir.join("workspace.yaml");
670 if let Ok(content) = std::fs::read_to_string(workspace_yaml) {
671 for line in content.lines() {
672 let trimmed = line.trim();
673 if let Some(rest) = trimmed
674 .strip_prefix("cwd:")
675 .or_else(|| trimmed.strip_prefix("workspace:"))
676 .or_else(|| trimmed.strip_prefix("path:"))
677 {
678 return Some(rest.trim().trim_matches('"').to_string());
679 }
680 }
681 }
682 None
683}
684
685fn copilot_session_matches_workspace(session_dir: &Path, workspace: &str) -> bool {
686 if let Some(candidate) = read_copilot_workspace_path(session_dir) {
687 return candidate == workspace;
688 }
689
690 let events_path = session_dir.join("events.jsonl");
691 let file = match std::fs::File::open(events_path) {
692 Ok(file) => file,
693 Err(_) => return false,
694 };
695 let reader = BufReader::new(file);
696 for line in reader.lines().map_while(Result::ok).take(8) {
697 let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
698 continue;
699 };
700 let Some(data) = value.get("data") else {
701 continue;
702 };
703 let candidate = data
704 .get("context")
705 .and_then(|context| context.get("cwd").or_else(|| context.get("gitRoot")))
706 .and_then(|value| value.as_str());
707 if candidate == Some(workspace) {
708 return true;
709 }
710 }
711 false
712}
713
714fn system_time_from_utc(value: chrono::DateTime<chrono::Utc>) -> std::time::SystemTime {
715 std::time::SystemTime::UNIX_EPOCH
716 + std::time::Duration::from_secs(value.timestamp().max(0) as u64)
717}
718
719#[async_trait]
720impl Agent for Copilot {
721 fn name(&self) -> &str {
722 "copilot"
723 }
724
725 fn default_model() -> &'static str {
726 DEFAULT_MODEL
727 }
728
729 fn model_for_size(size: ModelSize) -> &'static str {
730 match size {
731 ModelSize::Small => "claude-haiku-4.5",
732 ModelSize::Medium => "claude-sonnet-4.6",
733 ModelSize::Large => "claude-opus-4.6",
734 }
735 }
736
737 fn available_models() -> &'static [&'static str] {
738 AVAILABLE_MODELS
739 }
740
741 fn system_prompt(&self) -> &str {
742 &self.system_prompt
743 }
744
745 fn set_system_prompt(&mut self, prompt: String) {
746 self.system_prompt = prompt;
747 }
748
749 fn get_model(&self) -> &str {
750 &self.model
751 }
752
753 fn set_model(&mut self, model: String) {
754 self.model = model;
755 }
756
757 fn set_root(&mut self, root: String) {
758 self.root = Some(root);
759 }
760
761 fn set_skip_permissions(&mut self, skip: bool) {
762 self.skip_permissions = skip;
763 }
764
765 fn set_output_format(&mut self, format: Option<String>) {
766 self.output_format = format;
767 }
768
769 fn set_add_dirs(&mut self, dirs: Vec<String>) {
770 self.add_dirs = dirs;
771 }
772
773 fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
774 self.env_vars = vars;
775 }
776
777 fn set_capture_output(&mut self, capture: bool) {
778 self.capture_output = capture;
779 }
780
781 fn set_sandbox(&mut self, config: SandboxConfig) {
782 self.sandbox = Some(config);
783 }
784
785 fn set_max_turns(&mut self, turns: u32) {
786 self.max_turns = Some(turns);
787 }
788
789 fn as_any_ref(&self) -> &dyn std::any::Any {
790 self
791 }
792
793 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
794 self
795 }
796
797 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
798 self.execute(false, prompt).await
799 }
800
801 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
802 self.execute(true, prompt).await?;
803 Ok(())
804 }
805
806 async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
807 let mut args = if let Some(session_id) = session_id {
808 vec!["--resume".to_string(), session_id.to_string()]
809 } else if last {
810 vec!["--continue".to_string()]
811 } else {
812 vec!["--resume".to_string()]
813 };
814
815 if self.skip_permissions {
816 args.push("--allow-all".to_string());
817 }
818
819 if !self.model.is_empty() {
820 args.extend(["--model".to_string(), self.model.clone()]);
821 }
822
823 for dir in &self.add_dirs {
824 args.extend(["--add-dir".to_string(), dir.clone()]);
825 }
826
827 let mut cmd = self.make_command(args);
828
829 cmd.stdin(Stdio::inherit())
830 .stdout(Stdio::inherit())
831 .stderr(Stdio::inherit());
832
833 let status = cmd
834 .status()
835 .await
836 .context("Failed to execute 'copilot' CLI. Is it installed and in PATH?")?;
837 if !status.success() {
838 return Err(crate::process::ProcessError {
839 exit_code: status.code(),
840 stderr: String::new(),
841 agent_name: "Copilot".to_string(),
842 }
843 .into());
844 }
845 Ok(())
846 }
847
848 async fn cleanup(&self) -> Result<()> {
849 log::debug!("Cleaning up Copilot agent resources");
850 let base = self.get_base_path();
851 let instructions_file = base.join(".github/instructions/agent/agent.instructions.md");
852
853 if instructions_file.exists() {
854 fs::remove_file(&instructions_file).await?;
855 }
856
857 let agent_dir = base.join(".github/instructions/agent");
859 if agent_dir.exists()
860 && fs::read_dir(&agent_dir)
861 .await?
862 .next_entry()
863 .await?
864 .is_none()
865 {
866 fs::remove_dir(&agent_dir).await?;
867 }
868
869 let instructions_dir = base.join(".github/instructions");
870 if instructions_dir.exists()
871 && fs::read_dir(&instructions_dir)
872 .await?
873 .next_entry()
874 .await?
875 .is_none()
876 {
877 fs::remove_dir(&instructions_dir).await?;
878 }
879
880 let github_dir = base.join(".github");
881 if github_dir.exists()
882 && fs::read_dir(&github_dir)
883 .await?
884 .next_entry()
885 .await?
886 .is_none()
887 {
888 fs::remove_dir(&github_dir).await?;
889 }
890
891 Ok(())
892 }
893}