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