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