diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md
index 909dd86..7fe0940 100644
--- a/docs/AGENT_ROADMAP.md
+++ b/docs/AGENT_ROADMAP.md
@@ -284,15 +284,15 @@
| # | 기능 | 설명 | 우선순위 | 갭 |
|---|------|------|----------|----|
| 18-A1 | **코디네이터 에이전트 모드** | 계획·라우팅 전담, 구현은 서브에이전트 위임 | 최고 | G1 |
-| 18-A2 | **Worktree 격리 서브에이전트** | 서브에이전트별 독립 git working copy. 승인 후 병합 | 최고 | — |
-| 18-A3 | **에이전트 팀 위임 (delegate 도구)** | 특화 에이전트(코드·문서·보안·리서치) 독립 루프 실행 | 최고 | G1 |
-| 18-A4 | **백그라운드 에이전트 + 타입별 메모리** | 비동기 실행 + 트레이 알림. agent-memory/타입/MEMORY.md | 높음 | — |
+| 18-A2 ✅ | **Worktree 격리 서브에이전트** | WorktreeManager 구현 완료 + DelegateAgentTool에 주입 (isolation:"worktree" 파라미터 지원) | 최고 | — |
+| 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 |
+| 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — |
### Group B — 디버깅 + 생태계
| # | 기능 | 설명 | 우선순위 | 갭 |
|---|------|------|----------|----|
-| 18-B1 | **에이전트 리플레이/디버깅** | Event-Sourced 로그. 과거 세션 재생·분기 재실행 | 높음 | G8 |
+| 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 |
| 18-C1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 기반 인앱 갤러리. zip 설치 | 높음 | — |
| 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — |
| 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — |
diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs
index 11e9f41..da342ed 100644
--- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs
+++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Text;
using System.Text.Json;
@@ -70,7 +71,11 @@ public class SkillManagerTool : IAgentTool
private static ToolResult ListSkills()
{
- var skills = SkillService.Skills;
+ // Phase 17-D3: UserInvocable=false 스킬은 내부 전용이므로 목록에서 제외
+ var skills = SkillService.Skills
+ .Where(s => s.IsUserInvocable())
+ .ToList();
+
if (skills.Count == 0)
return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요.");
diff --git a/src/AxCopilot/Services/Agent/ToolRegistry.cs b/src/AxCopilot/Services/Agent/ToolRegistry.cs
index 05cfc2e..5632354 100644
--- a/src/AxCopilot/Services/Agent/ToolRegistry.cs
+++ b/src/AxCopilot/Services/Agent/ToolRegistry.cs
@@ -11,6 +11,9 @@ public class ToolRegistry : IDisposable
/// 등록된 모든 도구 목록.
public IReadOnlyCollection All => _tools.Values;
+ /// Phase 18-A: 백그라운드 에이전트 서비스 (AgentCompleted 이벤트 구독용).
+ public BackgroundAgentService? BackgroundAgentService { get; private set; }
+
/// 도구를 이름으로 찾습니다.
public IAgentTool? Get(string name) =>
_tools.TryGetValue(name, out var tool) ? tool : null;
@@ -161,7 +164,9 @@ public class ToolRegistry : IDisposable
// Phase 18-A: 위임 에이전트 (멀티에이전트 오케스트레이션)
var memoryRepo = new AgentTypeMemoryRepository();
var bgService = new BackgroundAgentService(memoryRepo);
- registry.Register(new DelegateAgentTool(bgService, memoryRepo));
+ var worktreeMgr = new WorktreeManager(); // Phase 18-A2: worktree 격리
+ registry.BackgroundAgentService = bgService; // ChatWindow에서 AgentCompleted 구독
+ registry.Register(new DelegateAgentTool(bgService, memoryRepo, worktreeMgr));
return registry;
}
diff --git a/src/AxCopilot/Views/ChatWindow.AgentSupport.cs b/src/AxCopilot/Views/ChatWindow.AgentSupport.cs
index a2a9375..f0602c1 100644
--- a/src/AxCopilot/Views/ChatWindow.AgentSupport.cs
+++ b/src/AxCopilot/Views/ChatWindow.AgentSupport.cs
@@ -407,6 +407,32 @@ public partial class ChatWindow
}
}
+ // ─── Phase 18-A: 백그라운드 에이전트 완료 알림 ──────────────────────────
+
+ ///
+ /// 위임 에이전트(delegate_agent)가 백그라운드에서 완료되면 트레이 알림을 표시합니다.
+ ///
+ private void OnBackgroundAgentCompleted(object? sender, AgentCompletedEventArgs e)
+ {
+ // UI 스레드에서 안전하게 실행
+ Dispatcher.BeginInvoke(() =>
+ {
+ var task = e.Task;
+ if (e.Error != null)
+ {
+ Services.NotificationService.Notify(
+ $"에이전트 '{task.AgentType}' 실패",
+ $"ID {task.Id}: {e.Error[..Math.Min(80, e.Error.Length)]}");
+ }
+ else
+ {
+ Services.NotificationService.Notify(
+ $"에이전트 '{task.AgentType}' 완료",
+ $"ID {task.Id}: {task.Description[..Math.Min(60, task.Description.Length)]}");
+ }
+ });
+ }
+
/// 에이전트 루프 동안 누적 토큰 (하단 바 표시용)
private int _agentCumulativeInputTokens;
private int _agentCumulativeOutputTokens;
diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs
index c67131c..ad955e9 100644
--- a/src/AxCopilot/Views/ChatWindow.xaml.cs
+++ b/src/AxCopilot/Views/ChatWindow.xaml.cs
@@ -104,6 +104,10 @@ public partial class ChatWindow : Window
},
};
+ // Phase 18-A: 백그라운드 에이전트 완료 알림 구독
+ if (_toolRegistry.BackgroundAgentService != null)
+ _toolRegistry.BackgroundAgentService.AgentCompleted += OnBackgroundAgentCompleted;
+
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "active";
diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml
index 94efa8a..1439508 100644
--- a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml
+++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml
@@ -114,6 +114,16 @@
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
+
+
+
+
+
+
+
@@ -153,6 +163,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_tokenTrend = new();
private long _lastEventMs; // 워터폴 시작 시간 기준
+ // Phase 18-B: 리플레이 ViewModel
+ private readonly ReplayTimelineViewModel _replayVm = new();
+
// ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ─────────────
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
@@ -177,6 +182,14 @@ public partial class WorkflowAnalyzerWindow : Window
RenderBottleneckCharts();
}
+ // Phase 18-B: 리플레이 탭 클릭
+ private void TabReplay_Click(object sender, MouseButtonEventArgs e)
+ {
+ _activeTab = "replay";
+ UpdateTabVisuals();
+ LoadReplaySessions();
+ }
+
private void UpdateTabVisuals()
{
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
@@ -184,18 +197,25 @@ public partial class WorkflowAnalyzerWindow : Window
?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
- var isTimeline = _activeTab == "timeline";
- TabTimeline.Background = isTimeline ? accentBrush : itemBg;
- TabBottleneck.Background = !isTimeline ? accentBrush : itemBg;
+ var isTimeline = _activeTab == "timeline";
+ var isBottleneck = _activeTab == "bottleneck";
+ var isReplay = _activeTab == "replay";
- // 활성 탭: 흰색 텍스트 (AccentColor 배경 위 가시성), 비활성: PrimaryText
- TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText;
- TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText;
- TabBottleneckIcon.Foreground = !isTimeline ? Brushes.White : primaryText;
- TabBottleneckText.Foreground = !isTimeline ? Brushes.White : primaryText;
+ TabTimeline.Background = isTimeline ? accentBrush : itemBg;
+ TabBottleneck.Background = isBottleneck ? accentBrush : itemBg;
+ TabReplay.Background = isReplay ? accentBrush : itemBg;
- TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed;
- BottleneckScroller.Visibility = !isTimeline ? Visibility.Visible : Visibility.Collapsed;
+ // 활성 탭: 흰색 텍스트, 비활성: PrimaryText
+ TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText;
+ TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText;
+ TabBottleneckIcon.Foreground = isBottleneck ? Brushes.White : primaryText;
+ TabBottleneckText.Foreground = isBottleneck ? Brushes.White : primaryText;
+ TabReplayIcon.Foreground = isReplay ? Brushes.White : primaryText;
+ TabReplayText.Foreground = isReplay ? Brushes.White : primaryText;
+
+ TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed;
+ BottleneckScroller.Visibility = isBottleneck ? Visibility.Visible : Visibility.Collapsed;
+ ReplayScroller.Visibility = isReplay ? Visibility.Visible : Visibility.Collapsed;
DetailPanel.Visibility = isTimeline && DetailPanel.Tag != null
? Visibility.Visible : Visibility.Collapsed;
}
@@ -271,4 +291,163 @@ public partial class WorkflowAnalyzerWindow : Window
}
}
+ // ─── Phase 18-B: 리플레이 탭 ──────────────────────────────────────────
+
+ private ReplaySessionInfo? _selectedReplaySession;
+
+ /// 저장된 세션 목록을 리플레이 패널에 로드합니다.
+ private void LoadReplaySessions()
+ {
+ _replayVm.LoadSessions();
+ ReplaySessionList.Children.Clear();
+
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.LightGray;
+ var itemHover = TryFindResource("ItemHoverBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+
+ if (_replayVm.Sessions.Count == 0)
+ {
+ ReplaySessionList.Children.Add(new TextBlock
+ {
+ Text = "저장된 세션이 없습니다.",
+ FontSize = 11,
+ Foreground = secondaryText,
+ Margin = new Thickness(4, 2, 0, 2),
+ });
+ return;
+ }
+
+ foreach (var session in _replayVm.Sessions)
+ {
+ var row = new Border
+ {
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(8, 4, 8, 4),
+ Margin = new Thickness(0, 1, 0, 1),
+ Cursor = System.Windows.Input.Cursors.Hand,
+ Tag = session,
+ };
+ row.Child = new TextBlock
+ {
+ Text = $"{session.CreatedAt:MM-dd HH:mm} [{session.SessionId[..Math.Min(8, session.SessionId.Length)]}] {session.FileSizeBytes / 1024}KB",
+ FontSize = 11,
+ Foreground = primaryText,
+ };
+ row.MouseEnter += (_, _) => row.Background = itemHover;
+ row.MouseLeave += (_, _) => row.Background = null;
+ row.MouseLeftButtonUp += (_, _) =>
+ {
+ _selectedReplaySession = session;
+ _replayVm.SelectedSession = session;
+ // 선택 강조
+ foreach (Border child in ReplaySessionList.Children.OfType())
+ child.BorderThickness = new Thickness(0);
+ row.BorderBrush = TryFindResource("AccentColor") as Brush;
+ row.BorderThickness = new Thickness(1);
+ };
+ ReplaySessionList.Children.Add(row);
+ }
+ }
+
+ private void BtnReplayStart_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (_replayVm.SelectedSession == null) return;
+
+ ReplayEventPanel.Children.Clear();
+ ReplayProgress.Value = 0;
+
+ // ReplayEventReceived 이벤트 구독 (중복 방지)
+ _replayVm.ReplayEventReceived -= OnReplayEventReceived;
+ _replayVm.ReplayEventReceived += OnReplayEventReceived;
+
+ _ = _replayVm.StartReplayAsync();
+ }
+
+ private void OnReplayEventReceived(ReplayEventItem item)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ var node = BuildReplayEventRow(item);
+ ReplayEventPanel.Children.Add(node);
+ ReplayEventScroll.ScrollToEnd();
+
+ // 진행 표시
+ if (_replayVm.ProgressMax > 0)
+ ReplayProgress.Value = (double)_replayVm.ProgressValue / _replayVm.ProgressMax * 100;
+ });
+ }
+
+ private void BtnReplayStop_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ => _replayVm.StopReplay();
+
+ private void ReplaySpeedSlider_Changed(object sender, System.Windows.RoutedPropertyChangedEventArgs e)
+ {
+ _replayVm.ReplaySpeedMs = (int)e.NewValue;
+ if (ReplaySpeedLabel != null)
+ ReplaySpeedLabel.Text = $"{(int)e.NewValue}ms";
+ }
+
+ /// 리플레이 이벤트 하나를 작은 행 UI로 빌드합니다.
+ private Border BuildReplayEventRow(ReplayEventItem item)
+ {
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var color = item.Record.Type switch
+ {
+ AgentEventLogType.ToolRequest => Color.FromRgb(0x60, 0xA5, 0xFA),
+ AgentEventLogType.ToolResult => Color.FromRgb(0x34, 0xD3, 0x99),
+ AgentEventLogType.Error => Color.FromRgb(0xF8, 0x71, 0x71),
+ AgentEventLogType.SessionEnd => Color.FromRgb(0x34, 0xD3, 0x99),
+ AgentEventLogType.AssistantMessage => Color.FromRgb(0xFB, 0xBF, 0x24),
+ _ => Color.FromRgb(0x94, 0xA3, 0xB8),
+ };
+ var icon = item.Icon;
+ var summary = item.Record.Payload ?? item.TypeLabel;
+
+ var grid = new System.Windows.Controls.Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(8, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var iconTb = new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 10,
+ Foreground = new SolidColorBrush(color),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetColumn(iconTb, 0);
+ grid.Children.Add(iconTb);
+
+ var summaryTb = new TextBlock
+ {
+ Text = summary.Length > 60 ? summary[..57] + "…" : summary,
+ FontSize = 10.5,
+ Foreground = primaryText,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0, 4, 0),
+ };
+ Grid.SetColumn(summaryTb, 1);
+ grid.Children.Add(summaryTb);
+
+ var timeTb = new TextBlock
+ {
+ Text = item.Record.Timestamp.ToString("HH:mm:ss"),
+ FontSize = 9,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetColumn(timeTb, 2);
+ grid.Children.Add(timeTb);
+
+ return new Border
+ {
+ Child = grid,
+ Padding = new Thickness(4, 2, 4, 2),
+ Margin = new Thickness(0, 1, 0, 1),
+ };
+ }
+
}