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