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