diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md
index 7fe0940..7a368d0 100644
--- a/docs/AGENT_ROADMAP.md
+++ b/docs/AGENT_ROADMAP.md
@@ -231,7 +231,7 @@
| 17-C1 | **훅 이벤트 확장** | UserPromptSubmit, PreCompact/PostCompact, FileChanged, CwdChanged, SessionEnd, ConfigChange 추가 | 최고 |
| 17-C2 | **훅 타입 확장** | type:prompt (LLM 보안검사), type:agent (에이전트 루프 검증) 추가 | 최고 |
| 17-C3 | **훅 속성·출력 고도화** | if/once/async/statusMessage 속성. additionalContext·permissionDecision·updatedInput 출력 | 높음 |
-| 17-C4 | **SessionStart 확장** | watchPaths (파일 감시 등록), initialUserMessage, additionalContext | 중간 |
+| 17-C4 ✅ | **SessionStart 확장** | watchPaths → FileWatcherService 등록 완료. SessionStart 훅 결과의 WatchPaths가 FileChanged 훅을 fire-and-forget으로 트리거 | 중간 |
### Group D — 스킬 시스템 고도화 (CC 스킬 문서 기반)
@@ -283,7 +283,7 @@
| # | 기능 | 설명 | 우선순위 | 갭 |
|---|------|------|----------|----|
-| 18-A1 | **코디네이터 에이전트 모드** | 계획·라우팅 전담, 구현은 서브에이전트 위임 | 최고 | G1 |
+| 18-A1 ✅ | **코디네이터 에이전트 모드** | CoordinatorAgentService 구현 완료. LLM 계획 수립 → 의존성 기반 토폴로지 정렬 → 병렬 서브에이전트 실행 → 결과 합성. EnableCoordinatorMode 설정 연동. AgentLoopService.Coordinator.cs 파셜 분리 | 최고 | G1 |
| 18-A2 ✅ | **Worktree 격리 서브에이전트** | WorktreeManager 구현 완료 + DelegateAgentTool에 주입 (isolation:"worktree" 파라미터 지원) | 최고 | — |
| 18-A3 ✅ | **에이전트 팀 위임 (delegate 도구)** | DelegateAgentTool 구현 + ToolRegistry 등록 완료. BackgroundAgentService + WorktreeManager 통합 | 최고 | G1 |
| 18-A4 ✅ | **백그라운드 에이전트 + 완료 알림** | BackgroundAgentService.AgentCompleted → ChatWindow 트레이 알림 연결 완료 | 높음 | — |
@@ -293,7 +293,7 @@
| # | 기능 | 설명 | 우선순위 | 갭 |
|---|------|------|----------|----|
| 18-B1 ✅ | **에이전트 리플레이/디버깅** | AgentReplayService + ReplayTimelineViewModel 구현. WorkflowAnalyzerWindow에 "리플레이" 탭 통합 (세션 선택·재생 컨트롤·이벤트 스트림) | 높음 | G8 |
-| 18-C1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 기반 인앱 갤러리. zip 설치 | 높음 | — |
+| 18-C1 ✅ | **플러그인 갤러리 + 레지스트리** | PluginGalleryViewModel + PluginInstallService + SettingsWindow 플러그인 탭 구현 완료. zip 설치·활성화 토글·제거 UI 완비 | 높음 | — |
| 18-C2 | **AI 스니펫** | ;email {수신자} {주제} 등 LLM 초안 자동 생성 | 중간 | — |
| 18-C3 | **파라미터 퀵링크** | jira {번호} → URL 변수 치환 | 중간 | — |
| 18-C4 | **오프라인 AI (ONNX Runtime)** | 로컬 소형 모델, 별도 배포 | 낮음 | — |
diff --git a/src/AxCopilot/Models/AppSettings.LlmSettings.cs b/src/AxCopilot/Models/AppSettings.LlmSettings.cs
index 50ee873..97da4c8 100644
--- a/src/AxCopilot/Models/AppSettings.LlmSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.LlmSettings.cs
@@ -368,6 +368,10 @@ public class LlmSettings
[JsonPropertyName("enableDiffTracker")]
public bool EnableDiffTracker { get; set; } = true;
+ /// Phase 18-A1: 코디네이터 에이전트 모드 활성화. true이면 LLM이 먼저 계획을 수립 후 서브에이전트에 위임. 기본 false.
+ [JsonPropertyName("enableCoordinatorMode")]
+ public bool EnableCoordinatorMode { get; set; } = false;
+
/// 추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.
[JsonPropertyName("skillsFolderPath")]
public string SkillsFolderPath { get; set; } = "";
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs
new file mode 100644
index 0000000..8b4a883
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.Coordinator.cs
@@ -0,0 +1,100 @@
+namespace AxCopilot.Services.Agent;
+
+///
+/// Phase 18-A1: 코디네이터 에이전트 모드.
+/// LLM이 계획을 수립하고 서브에이전트들에게 태스크를 위임합니다.
+///
+public partial class AgentLoopService
+{
+ ///
+ /// 코디네이터 모드 실행.
+ /// 성공하면 최종 합성 결과를 반환, 실패 시 null 반환 (일반 에이전트 루프로 fallback).
+ ///
+ internal async Task RunCoordinatorModeAsync(
+ string userRequest,
+ string workFolder,
+ CancellationToken ct)
+ {
+ try
+ {
+ var coordService = new CoordinatorAgentService(_llm);
+
+ // 1단계: 계획 수립
+ var reqPreview = userRequest.Length > 60 ? userRequest[..57] + "…" : userRequest;
+ EmitEvent(AgentEventType.Planning, "coordinator",
+ $"코디네이터 모드: 요청 분석 중... [{reqPreview}]");
+
+ var plan = await coordService.PlanAsync(userRequest, ct);
+
+ if (plan.Tasks.Count == 0)
+ return null; // 계획 실패 → 일반 루프 fallback
+
+ // 계획 요약 이벤트
+ var planSummary = string.Join(", ", plan.Tasks.Select(t => $"{t.AgentType}:{t.Id}"));
+ EmitEvent(AgentEventType.Planning, "coordinator",
+ $"계획 수립 완료 ({plan.Tasks.Count}개 태스크): {planSummary}");
+
+ // 2단계: 사용자 승인 (UserDecisionCallback이 있는 경우)
+ if (UserDecisionCallback != null)
+ {
+ var planText = FormatCoordinatorPlan(plan);
+ var options = new List { "실행", "취소" };
+ var answer = await UserDecisionCallback(planText, options);
+
+ if (answer == null || answer.Contains("취소", StringComparison.OrdinalIgnoreCase))
+ {
+ EmitEvent(AgentEventType.Complete, "coordinator", "사용자가 코디네이터 계획을 취소했습니다.");
+ return "코디네이터 계획이 취소되었습니다.";
+ }
+ }
+
+ // 3단계: 각 서브태스크 실행
+ var result = await coordService.ExecuteAsync(
+ plan,
+ workFolder,
+ RunSubAgentAsync,
+ onTaskStarted: (id, desc) =>
+ EmitEvent(AgentEventType.ToolCall, $"coordinator/{id}",
+ $"[{id}] {desc}"),
+ onTaskCompleted: (id, res) =>
+ EmitEvent(AgentEventType.ToolResult, $"coordinator/{id}",
+ $"[{id}] 완료: {(res.Length > 100 ? res[..97] + "…" : res)}"),
+ ct);
+
+ EmitEvent(AgentEventType.Complete, "coordinator", "코디네이터 모드 완료");
+ return result;
+ }
+ catch (OperationCanceledException)
+ {
+ EmitEvent(AgentEventType.Complete, "coordinator", "취소됨");
+ return "코디네이터 모드가 취소되었습니다.";
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"[Coordinator] 오류: {ex.Message}. 일반 에이전트 루프로 fallback.");
+ EmitEvent(AgentEventType.Error, "coordinator", $"코디네이터 오류: {ex.Message} — 일반 모드로 전환");
+ return null; // fallback to normal loop
+ }
+ }
+
+ /// 코디네이터 계획을 사용자에게 보여줄 텍스트로 포맷합니다.
+ private static string FormatCoordinatorPlan(CoordinatorPlan plan)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"📋 코디네이터 계획 ({plan.Tasks.Count}개 태스크)");
+ sb.AppendLine($"요청: {plan.OriginalRequest}");
+ sb.AppendLine();
+
+ foreach (var task in plan.Tasks)
+ {
+ var deps = task.Dependencies.Count > 0
+ ? $" (선행: {string.Join(", ", task.Dependencies)})"
+ : "";
+ sb.AppendLine($" [{task.Id}] {task.AgentType}{deps}");
+ sb.AppendLine($" {task.Description}");
+ }
+
+ sb.AppendLine("\n이 계획을 실행할까요?");
+ return sb.ToString();
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs
index 918ee67..ed7f763 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.ExtendedHooks.cs
@@ -47,6 +47,32 @@ public partial class AgentLoopService
var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm);
ApplyExtendedHookResult(result, messages);
+ // Phase 17-C4: SessionStart 훅 WatchPaths → FileWatcherService 등록
+ if (kind == HookEventKind.SessionStart &&
+ result.WatchPaths != null && result.WatchPaths.Count > 0)
+ {
+ _fileWatcher.ClearAll(); // 이전 세션 감시 경로 초기화
+ var workFolder = _settings.Settings.Llm.WorkFolder ?? "";
+ foreach (var pathPattern in result.WatchPaths)
+ {
+ // 상대 경로는 workFolder 기준으로 해석
+ var absPattern = System.IO.Path.IsPathRooted(pathPattern)
+ ? pathPattern
+ : System.IO.Path.Combine(workFolder, pathPattern);
+ _fileWatcher.Watch(absPattern, changedPath =>
+ {
+ // FileChanged 훅 fire-and-forget
+ _ = RunExtendedEventAsync(
+ HookEventKind.FileChanged, null, CancellationToken.None,
+ changedFile: changedPath);
+ EmitEvent(AgentEventType.Thinking, "file_watcher",
+ $"파일 변경 감지: {System.IO.Path.GetFileName(changedPath)}");
+ });
+ }
+ EmitEvent(AgentEventType.Thinking, "file_watcher",
+ $"watchPaths 등록 완료 ({result.WatchPaths.Count}개 패턴)");
+ }
+
// HookFired 이벤트 로그 기록
if (_eventLog != null && hooks.Count > 0)
_ = _eventLog.AppendAsync(AgentEventLogType.HookFired,
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs
index a257f9b..792e9ce 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs
@@ -78,6 +78,9 @@ public partial class AgentLoopService
/// 위임 에이전트 도구 참조 (서브에이전트 실행기 주입용).
private DelegateAgentTool? _delegateAgentTool;
+ /// Phase 17-C4: watchPaths 파일 감시 서비스.
+ private readonly FileWatcherService _fileWatcher = new();
+
/// 일시정지 제어용 세마포어. 1이면 진행, 0이면 대기.
private readonly SemaphoreSlim _pauseSemaphore = new(1, 1);
@@ -224,6 +227,18 @@ public partial class AgentLoopService
var context = BuildContext(activeTabSnapshot);
+ // Phase 18-A1: 코디네이터 에이전트 모드 — 계획 수립 후 서브에이전트 위임
+ if (llm.EnableCoordinatorMode && !string.IsNullOrWhiteSpace(userQuery))
+ {
+ var coordResult = await RunCoordinatorModeAsync(userQuery, llm.WorkFolder ?? "", ct);
+ if (coordResult != null)
+ {
+ IsRunning = false;
+ return coordResult;
+ }
+ // null 반환 시 일반 에이전트 루프로 fallback
+ }
+
// Phase 17-A: Reflexion — 과거 교훈을 시스템 메시지에 주입
await InjectReflexionContextAsync(messages, userQuery);
diff --git a/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs b/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs
new file mode 100644
index 0000000..276ad2d
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/CoordinatorAgentService.cs
@@ -0,0 +1,265 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using AxCopilot.Models;
+
+namespace AxCopilot.Services.Agent;
+
+///
+/// Phase 18-A1: 코디네이터 에이전트 서비스.
+/// 사용자 요청을 분석하여 멀티 서브태스크 계획을 수립하고,
+/// 각 태스크를 특화 서브에이전트에 위임하여 실행합니다.
+///
+public class CoordinatorAgentService
+{
+ private readonly LlmService _llm;
+
+ // 사용 가능한 에이전트 유형
+ private static readonly string[] KnownAgentTypes =
+ {
+ "implementer", // 코드 작성·수정
+ "researcher", // 조사·분석·요약
+ "reviewer", // 코드·문서 리뷰
+ "documenter", // 문서·보고서 작성
+ "tester", // 테스트 생성·실행
+ };
+
+ private const string PlanSystemPrompt = @"당신은 복잡한 작업을 분석하여 서브태스크로 분해하는 코디네이터 에이전트입니다.
+사용자의 요청을 받아 실행 계획을 JSON 형식으로 반환하세요.
+
+사용 가능한 에이전트 유형:
+- implementer: 코드 작성 및 수정
+- researcher: 조사, 분석, 요약
+- reviewer: 코드·문서 리뷰
+- documenter: 문서·보고서 작성
+- tester: 테스트 생성 및 실행
+
+반환 형식 (JSON만 반환, 설명 없음):
+{
+ ""original_request"": ""원본 요청"",
+ ""tasks"": [
+ {
+ ""id"": ""task1"",
+ ""agent_type"": ""researcher"",
+ ""description"": ""태스크 설명"",
+ ""dependencies"": []
+ },
+ {
+ ""id"": ""task2"",
+ ""agent_type"": ""implementer"",
+ ""description"": ""태스크 설명"",
+ ""dependencies"": [""task1""]
+ }
+ ]
+}
+
+규칙:
+- 독립적으로 실행 가능한 태스크는 의존성 없이 병렬 실행 가능
+- 선행 태스크 결과가 필요한 경우에만 dependencies에 추가
+- 최대 6개 태스크로 분해
+- JSON 외 다른 텍스트 없이 순수 JSON만 반환";
+
+ public CoordinatorAgentService(LlmService llm)
+ {
+ _llm = llm;
+ }
+
+ /// 사용자 요청을 분석하여 CoordinatorPlan을 생성합니다.
+ public async Task PlanAsync(string request, CancellationToken ct)
+ {
+ var messages = new List
+ {
+ new() { Role = "system", Content = PlanSystemPrompt },
+ new() { Role = "user", Content = $"다음 요청을 분석하여 실행 계획을 수립하세요:\n\n{request}" }
+ };
+
+ var response = await _llm.SendAsync(messages, ct);
+ return ParsePlan(request, response);
+ }
+
+ ///
+ /// CoordinatorPlan을 실행합니다. 의존성 순서에 따라 태스크를 실행하고
+ /// 각 태스크 결과를 후속 태스크에 컨텍스트로 전달합니다.
+ ///
+ /// 실행할 계획
+ /// 작업 폴더
+ /// 서브에이전트 실행기 (agentType, task, context, ct) → result
+ /// 태스크 시작 콜백 (taskId, description)
+ /// 태스크 완료 콜백 (taskId, result)
+ /// 취소 토큰
+ public async Task ExecuteAsync(
+ CoordinatorPlan plan,
+ string workFolder,
+ Func> subAgentRunner,
+ Action? onTaskStarted = null,
+ Action? onTaskCompleted = null,
+ CancellationToken ct = default)
+ {
+ var results = new Dictionary(); // taskId → result
+ var remaining = plan.Tasks.ToList();
+
+ // 의존성 기반 토폴로지 정렬 실행
+ while (remaining.Count > 0 && !ct.IsCancellationRequested)
+ {
+ // 현재 실행 가능한 태스크 찾기 (의존성이 모두 완료된 것)
+ var ready = remaining
+ .Where(t => t.Dependencies.All(dep => results.ContainsKey(dep)))
+ .ToList();
+
+ if (ready.Count == 0)
+ {
+ // 순환 의존성 또는 누락된 의존성 — 강제 진행
+ ready = new List { remaining[0] };
+ LogService.Warn($"[Coordinator] 의존성 해결 실패, 강제 실행: {remaining[0].Id}");
+ }
+
+ // 의존성 없는 태스크는 병렬 실행
+ var parallelTasks = ready.Select(async task =>
+ {
+ task.Status = SubTaskStatus.Running;
+ onTaskStarted?.Invoke(task.Id, task.Description);
+
+ // 선행 태스크 결과를 컨텍스트로 구성
+ var context = BuildContext(task, results, plan.OriginalRequest);
+
+ try
+ {
+ var result = await subAgentRunner(task.AgentType, task.Description, context, ct);
+ task.Result = result;
+ task.Status = SubTaskStatus.Completed;
+ onTaskCompleted?.Invoke(task.Id, result);
+ return (task.Id, result, (Exception?)null);
+ }
+ catch (Exception ex)
+ {
+ task.Error = ex.Message;
+ task.Status = SubTaskStatus.Failed;
+ onTaskCompleted?.Invoke(task.Id, $"[오류] {ex.Message}");
+ return (task.Id, $"[오류] {ex.Message}", ex);
+ }
+ });
+
+ var parallelResults = await Task.WhenAll(parallelTasks);
+ foreach (var (id, result, _) in parallelResults)
+ results[id] = result;
+
+ // 완료된 태스크 제거
+ foreach (var t in ready)
+ remaining.Remove(t);
+ }
+
+ // 최종 결과 합성
+ return await SynthesizeResultAsync(plan, results, ct);
+ }
+
+ /// 모든 서브태스크 결과를 종합하여 최종 응답을 생성합니다.
+ private async Task SynthesizeResultAsync(
+ CoordinatorPlan plan,
+ Dictionary results,
+ CancellationToken ct)
+ {
+ if (results.Count == 0) return "실행된 태스크가 없습니다.";
+
+ // 단일 태스크면 바로 반환
+ if (plan.Tasks.Count == 1 && results.Count == 1)
+ return results.Values.First();
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"원본 요청: {plan.OriginalRequest}");
+ sb.AppendLine();
+ foreach (var task in plan.Tasks)
+ {
+ if (results.TryGetValue(task.Id, out var result))
+ {
+ sb.AppendLine($"[{task.AgentType}] {task.Description}:");
+ sb.AppendLine(result.Length > 500 ? result[..500] + "…" : result);
+ sb.AppendLine();
+ }
+ }
+
+ var synthMessages = new List
+ {
+ new() { Role = "system", Content = "당신은 여러 서브에이전트 결과를 종합하는 코디네이터입니다. 결과를 간결하게 통합하여 최종 응답을 제공하세요." },
+ new() { Role = "user", Content = sb.ToString() }
+ };
+
+ return await _llm.SendAsync(synthMessages, ct);
+ }
+
+ // ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
+
+ private static string BuildContext(
+ CoordinatorSubTask task,
+ Dictionary previousResults,
+ string originalRequest)
+ {
+ var ctx = new System.Text.StringBuilder();
+ ctx.AppendLine($"원본 요청: {originalRequest}");
+ ctx.AppendLine($"현재 태스크: {task.Description}");
+
+ if (task.Dependencies.Count > 0)
+ {
+ ctx.AppendLine("\n선행 태스크 결과:");
+ foreach (var dep in task.Dependencies)
+ {
+ if (previousResults.TryGetValue(dep, out var depResult))
+ ctx.AppendLine($"- {dep}: {(depResult.Length > 300 ? depResult[..300] + "…" : depResult)}");
+ }
+ }
+
+ return ctx.ToString();
+ }
+
+ private static CoordinatorPlan ParsePlan(string originalRequest, string llmResponse)
+ {
+ try
+ {
+ // JSON 블록 추출 (```json ... ``` 또는 { ... })
+ var json = ExtractJson(llmResponse);
+ if (string.IsNullOrEmpty(json))
+ return FallbackPlan(originalRequest);
+
+ var plan = JsonSerializer.Deserialize(json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ if (plan == null || plan.Tasks.Count == 0)
+ return FallbackPlan(originalRequest);
+
+ // 에이전트 유형 검증
+ foreach (var task in plan.Tasks)
+ {
+ if (!KnownAgentTypes.Contains(task.AgentType, StringComparer.OrdinalIgnoreCase))
+ task.AgentType = "implementer";
+ }
+
+ return plan;
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"[Coordinator] 계획 파싱 실패: {ex.Message}");
+ return FallbackPlan(originalRequest);
+ }
+ }
+
+ private static string ExtractJson(string text)
+ {
+ // ```json ... ``` 블록
+ var m = Regex.Match(text, @"```(?:json)?\s*([\s\S]*?)\s*```");
+ if (m.Success) return m.Groups[1].Value.Trim();
+
+ // 첫 번째 { ... } 블록
+ var start = text.IndexOf('{');
+ var end = text.LastIndexOf('}');
+ if (start >= 0 && end > start) return text[start..(end + 1)];
+
+ return "";
+ }
+
+ private static CoordinatorPlan FallbackPlan(string request) => new()
+ {
+ OriginalRequest = request,
+ Tasks = new List
+ {
+ new() { Id = "task1", AgentType = "implementer", Description = request, Dependencies = new() }
+ }
+ };
+}
diff --git a/src/AxCopilot/Views/ChatWindow.Sending.cs b/src/AxCopilot/Views/ChatWindow.Sending.cs
index 408ab80..d6dfe22 100644
--- a/src/AxCopilot/Views/ChatWindow.Sending.cs
+++ b/src/AxCopilot/Views/ChatWindow.Sending.cs
@@ -264,6 +264,14 @@ public partial class ChatWindow
// placeholder 정리
ClearPromptCardPlaceholder();
+ // Phase 18: SlashCommandRegistry 명령 우선 실행 (/compact, /memory, /plan 등)
+ if (text.StartsWith("/"))
+ {
+ using var regCts = new CancellationTokenSource();
+ if (await TryExecuteRegistrySlashCommandAsync(text, regCts.Token))
+ return; // registry 명령이 처리됨 (내장 SlashCommands dict는 폴백)
+ }
+
// 슬래시 명령어 처리
var (slashSystem, displayText) = ParseSlashCommand(text);
diff --git a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs
index e1b0f62..65c5b9d 100644
--- a/src/AxCopilot/Views/ChatWindow.SlashCommands.cs
+++ b/src/AxCopilot/Views/ChatWindow.SlashCommands.cs
@@ -68,6 +68,14 @@ public partial class ChatWindow
.Select(kv => (Cmd: kv.Key, Label: kv.Value.Label, IsSkill: false))
.ToList();
+ // SlashCommandRegistry 명령 매칭 (/compact, /memory, /plan 등)
+ foreach (var rm in GetRegistrySlashMatches(text))
+ {
+ // 내장 dict나 스킬에 이미 있는 것 제외 (중복 방지)
+ if (!matches.Any(m => m.Cmd.Equals(rm.Cmd, StringComparison.OrdinalIgnoreCase)))
+ matches.Add(rm);
+ }
+
// 스킬 슬래시 명령어 매칭 (탭별 필터)
if (Llm.EnableSkillSystem)
{
diff --git a/src/AxCopilot/Views/ChatWindow.SlashContext.cs b/src/AxCopilot/Views/ChatWindow.SlashContext.cs
new file mode 100644
index 0000000..cb94c3f
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.SlashContext.cs
@@ -0,0 +1,184 @@
+using System.Windows;
+using AxCopilot.Models;
+using AxCopilot.Services;
+using AxCopilot.Services.Agent;
+using AxCopilot.Services.Agent.SlashCommands;
+
+namespace AxCopilot.Views;
+
+///
+/// Phase 18: SlashCommandRegistry IAgentChatContext 구현.
+/// ChatWindow가 IAgentChatContext를 구현하여 SlashCommandRegistry 명령에
+/// LLM, 설정, UI 조작 기능을 제공합니다.
+///
+public partial class ChatWindow : IAgentChatContext
+{
+ // ─── SlashCommandRegistry 연결 ──────────────────────────────────────────
+
+ private readonly SlashCommandRegistry _slashRegistry = SlashCommandRegistry.CreateDefault();
+
+ // IAgentChatContext 구현
+
+ LlmService IAgentChatContext.Llm => _llm;
+
+ SettingsService IAgentChatContext.Settings => _settings;
+
+ IReadOnlyList IAgentChatContext.LoadedSkills => SkillService.Skills;
+
+ IReadOnlyList IAgentChatContext.Hooks
+ {
+ get
+ {
+ var cfg = _settings.Settings.Llm.ExtendedHooks;
+ var allHooks = new List();
+
+ void AddHooks(IEnumerable? configs, HookEventKind kind)
+ {
+ if (configs == null) return;
+ foreach (var c in configs.Where(x => x.Enabled))
+ allHooks.Add(new ExtendedHookEntry
+ {
+ Name = c.Name,
+ Event = kind,
+ Matcher = c.Matcher,
+ Mode = ParseHookMode(c.Mode),
+ ScriptPath = c.ScriptPath,
+ Url = c.Url,
+ Prompt = c.Prompt,
+ Model = c.Model,
+ Enabled = c.Enabled,
+ Once = c.Once,
+ IsAsync = c.IsAsync,
+ TimeoutSeconds = c.TimeoutSeconds > 0 ? c.TimeoutSeconds : 30,
+ StatusMessage = c.StatusMessage,
+ });
+ }
+
+ AddHooks(cfg.PreToolUse, HookEventKind.PreToolUse);
+ AddHooks(cfg.PostToolUse, HookEventKind.PostToolUse);
+ AddHooks(cfg.PostToolUseFailure, HookEventKind.PostToolUseFailure);
+ AddHooks(cfg.SessionStart, HookEventKind.SessionStart);
+ AddHooks(cfg.SessionEnd, HookEventKind.SessionEnd);
+ AddHooks(cfg.UserPromptSubmit, HookEventKind.UserPromptSubmit);
+ AddHooks(cfg.AgentStop, HookEventKind.AgentStop);
+ AddHooks(cfg.PreCompact, HookEventKind.PreCompact);
+ AddHooks(cfg.PostCompact, HookEventKind.PostCompact);
+ AddHooks(cfg.FileChanged, HookEventKind.FileChanged);
+
+ return allHooks;
+ }
+ }
+
+ string IAgentChatContext.ActiveTab => _activeTab;
+
+ string IAgentChatContext.WorkFolder => GetCurrentWorkFolder();
+
+ SlashCommandRegistry IAgentChatContext.CommandRegistry => _slashRegistry;
+
+ async Task IAgentChatContext.SendSystemMessageAsync(string message)
+ {
+ await Dispatcher.InvokeAsync(() =>
+ {
+ AddMessageBubble("assistant", $"ℹ️ {message}");
+ AutoScrollIfNeeded();
+ });
+ }
+
+ async Task IAgentChatContext.PromptUserAsync(string question)
+ {
+ string? response = null;
+ await Dispatcher.InvokeAsync(() =>
+ {
+ response = UserAskDialog.Show(question, new System.Collections.Generic.List());
+ });
+ return response;
+ }
+
+ void IAgentChatContext.ClearMessages()
+ {
+ Dispatcher.Invoke(() =>
+ {
+ MessagePanel.Children.Clear();
+ EmptyState.Visibility = Visibility.Visible;
+ lock (_convLock)
+ {
+ _currentConversation?.Messages.Clear();
+ }
+ });
+ }
+
+ void IAgentChatContext.UpdateSessionHeader()
+ {
+ Dispatcher.Invoke(() =>
+ {
+ UpdateModelLabel();
+ UpdatePlanModeUI();
+ });
+ }
+
+ void IAgentChatContext.ToggleSettingsPanel() => Dispatcher.Invoke(ToggleSettingsPanel);
+
+ // ─── SlashCommandRegistry → ChatWindow 팝업 통합 ──────────────────────
+
+ ///
+ /// SlashCommandRegistry 명령을 팝업 후보 목록에 추가합니다.
+ /// ChatWindow.SlashCommands.cs의 InputBox_TextChanged에서 호출됩니다.
+ ///
+ internal IEnumerable<(string Cmd, string Label, bool IsSkill)> GetRegistrySlashMatches(string prefix)
+ {
+ return _slashRegistry.GetAll()
+ .Where(c => ("/" + c.Name).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Select(c => (
+ Cmd: "/" + c.Name,
+ Label: c.Description,
+ IsSkill: false
+ ));
+ }
+
+ ///
+ /// 입력 텍스트가 SlashCommandRegistry 명령이면 실행합니다.
+ /// 실행 성공 시 true 반환.
+ ///
+ internal async Task TryExecuteRegistrySlashCommandAsync(
+ string fullInput, CancellationToken ct)
+ {
+ // "/compact 추가 지시사항" → name="compact", args="추가 지시사항"
+ var trimmed = fullInput.TrimStart('/');
+ var spaceIdx = trimmed.IndexOf(' ');
+ var name = spaceIdx >= 0 ? trimmed[..spaceIdx] : trimmed;
+ var args = spaceIdx >= 0 ? trimmed[(spaceIdx + 1)..].Trim() : "";
+
+ var command = _slashRegistry.Resolve("/" + name);
+ if (command == null) return false;
+
+ try
+ {
+ _isStreaming = true;
+ Dispatcher.Invoke(() => BtnSend.IsEnabled = false);
+ await command.ExecuteAsync(args, this, ct);
+ }
+ catch (OperationCanceledException) { /* 취소 */ }
+ catch (Exception ex)
+ {
+ await ((IAgentChatContext)this).SendSystemMessageAsync($"명령 실행 오류: {ex.Message}");
+ }
+ finally
+ {
+ _isStreaming = false;
+ Dispatcher.Invoke(() => BtnSend.IsEnabled = true);
+ }
+
+ return true;
+ }
+
+ // ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
+
+ private static HookExecutionMode ParseHookMode(string? mode) =>
+ mode?.ToLowerInvariant() switch
+ {
+ "http" => HookExecutionMode.Http,
+ "prompt" => HookExecutionMode.Prompt,
+ "agent" => HookExecutionMode.Agent,
+ _ => HookExecutionMode.Command,
+ };
+}
diff --git a/src/AxCopilot/Views/SettingsWindow.Plugins.cs b/src/AxCopilot/Views/SettingsWindow.Plugins.cs
new file mode 100644
index 0000000..8c5f38c
--- /dev/null
+++ b/src/AxCopilot/Views/SettingsWindow.Plugins.cs
@@ -0,0 +1,249 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using AxCopilot.Services.Agent;
+using AxCopilot.ViewModels;
+using Microsoft.Win32;
+
+namespace AxCopilot.Views;
+
+///
+/// Phase 18-C1: SettingsWindow — 플러그인 갤러리 탭.
+/// PluginGalleryViewModel을 코드비하인드에서 바인딩하여 플러그인 설치·관리를 지원합니다.
+///
+public partial class SettingsWindow
+{
+ private PluginGalleryViewModel? _pluginVm;
+
+ // ─── 초기화 ──────────────────────────────────────────────────────────────
+
+ private void InitPluginGallery()
+ {
+ if (_pluginVm != null) return;
+ var installService = new PluginInstallService();
+ _pluginVm = new PluginGalleryViewModel(installService);
+ _pluginVm.Refresh();
+ RenderPluginList();
+ }
+
+ // ─── 버튼 이벤트 ─────────────────────────────────────────────────────────
+
+ private async void BtnInstallPlugin_Click(object sender, MouseButtonEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "플러그인 zip 파일 선택",
+ Filter = "플러그인 패키지 (*.zip)|*.zip",
+ InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
+ };
+
+ if (dlg.ShowDialog() != true) return;
+
+ PluginStatusText.Text = "설치 중...";
+ BtnInstallPlugin.IsEnabled = false;
+
+ try
+ {
+ var svc = new PluginInstallService();
+ var result = await svc.InstallFromZipAsync(dlg.FileName);
+ PluginStatusText.Text = result.Success
+ ? $"✓ {result.Message}"
+ : $"✗ {result.Message}";
+
+ if (result.Success)
+ {
+ _pluginVm?.Refresh();
+ RenderPluginList();
+ }
+ }
+ finally
+ {
+ BtnInstallPlugin.IsEnabled = true;
+ }
+ }
+
+ private void BtnRefreshPlugins_Click(object sender, MouseButtonEventArgs e)
+ {
+ _pluginVm?.Refresh();
+ RenderPluginList();
+ PluginStatusText.Text = "새로고침 완료";
+ }
+
+ // ─── 플러그인 목록 렌더링 ──────────────────────────────────────────────────
+
+ private void RenderPluginList()
+ {
+ if (_pluginVm == null || PluginListPanel == null) return;
+ PluginListPanel.Children.Clear();
+
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var itemBg = TryFindResource("ItemBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
+ var hoverBg = TryFindResource("ItemHoverBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF));
+ var accentColor = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
+
+ if (_pluginVm.Plugins.Count == 0)
+ {
+ PluginListPanel.Children.Add(new TextBlock
+ {
+ Text = "설치된 플러그인이 없습니다.\n'zip에서 설치' 버튼으로 플러그인을 추가하세요.",
+ FontSize = 12,
+ Foreground = secondaryText,
+ TextWrapping = System.Windows.TextWrapping.Wrap,
+ Margin = new Thickness(0, 8, 0, 8),
+ LineHeight = 20,
+ });
+ return;
+ }
+
+ foreach (var plugin in _pluginVm.Plugins)
+ {
+ var card = new Border
+ {
+ Background = itemBg,
+ CornerRadius = new CornerRadius(10),
+ Padding = new Thickness(16, 12, 16, 12),
+ Margin = new Thickness(0, 0, 0, 8),
+ };
+
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ // 왼쪽: 플러그인 정보
+ var infoStack = new StackPanel();
+
+ // 이름 + 유형 배지
+ var nameRow = new StackPanel { Orientation = Orientation.Horizontal };
+ nameRow.Children.Add(new TextBlock
+ {
+ Text = plugin.Name,
+ FontSize = 13,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = primaryText,
+ });
+ var typeBadge = new Border
+ {
+ Background = GetTypeBadgeColor(plugin.Type),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(6, 2, 6, 2),
+ Margin = new Thickness(8, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ typeBadge.Child = new TextBlock
+ {
+ Text = plugin.Type,
+ FontSize = 10,
+ Foreground = Brushes.White,
+ FontWeight = FontWeights.SemiBold,
+ };
+ nameRow.Children.Add(typeBadge);
+ infoStack.Children.Add(nameRow);
+
+ infoStack.Children.Add(new TextBlock
+ {
+ Text = plugin.Description,
+ FontSize = 11,
+ Foreground = secondaryText,
+ Margin = new Thickness(0, 4, 0, 4),
+ TextWrapping = System.Windows.TextWrapping.Wrap,
+ });
+
+ infoStack.Children.Add(new TextBlock
+ {
+ Text = $"v{plugin.Version} | {plugin.Author} | 설치일: {plugin.InstalledAt}",
+ FontSize = 10,
+ Foreground = secondaryText,
+ });
+
+ Grid.SetColumn(infoStack, 0);
+ grid.Children.Add(infoStack);
+
+ // 오른쪽: 활성화 토글 + 제거
+ var actionStack = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ // 활성화 토글
+ var isEnabled = plugin.IsEnabled;
+ var toggleBorder = new Border
+ {
+ Width = 40, Height = 22, CornerRadius = new CornerRadius(11),
+ Background = isEnabled ? accentColor : itemBg,
+ BorderBrush = secondaryText, BorderThickness = new Thickness(1),
+ Cursor = Cursors.Hand,
+ Margin = new Thickness(0, 0, 10, 0),
+ Tag = plugin,
+ };
+ var toggleThumb = new Border
+ {
+ Width = 16, Height = 16, CornerRadius = new CornerRadius(8),
+ Background = Brushes.White,
+ HorizontalAlignment = isEnabled ? HorizontalAlignment.Right : HorizontalAlignment.Left,
+ Margin = new Thickness(3),
+ };
+ toggleBorder.Child = toggleThumb;
+ var capturedPlugin = plugin;
+ toggleBorder.MouseLeftButtonUp += async (_, _) =>
+ {
+ capturedPlugin.IsEnabled = !capturedPlugin.IsEnabled;
+ var svc = new PluginInstallService();
+ await svc.SetEnabledAsync(capturedPlugin.Id, capturedPlugin.IsEnabled);
+ RenderPluginList(); // 재렌더링
+ };
+ actionStack.Children.Add(toggleBorder);
+
+ // 제거 버튼
+ var removeBorder = new Border
+ {
+ Width = 28, Height = 28, CornerRadius = new CornerRadius(6),
+ Background = Brushes.Transparent, Cursor = Cursors.Hand,
+ };
+ removeBorder.Child = new TextBlock
+ {
+ Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 13,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ removeBorder.MouseEnter += (_, _) => removeBorder.Background = hoverBg;
+ removeBorder.MouseLeave += (_, _) => removeBorder.Background = Brushes.Transparent;
+ removeBorder.MouseLeftButtonUp += async (_, _) =>
+ {
+ var confirm = CustomMessageBox.Show(
+ $"'{capturedPlugin.Name}' 플러그인을 제거하시겠습니까?",
+ "플러그인 제거",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (confirm != MessageBoxResult.Yes) return;
+
+ var svc = new PluginInstallService();
+ await svc.UninstallAsync(capturedPlugin.Id);
+ _pluginVm?.Refresh();
+ RenderPluginList();
+ PluginStatusText.Text = $"'{capturedPlugin.Name}' 제거 완료";
+ };
+ actionStack.Children.Add(removeBorder);
+
+ Grid.SetColumn(actionStack, 1);
+ grid.Children.Add(actionStack);
+
+ card.Child = grid;
+ PluginListPanel.Children.Add(card);
+ }
+ }
+
+ private static Brush GetTypeBadgeColor(string type) => type?.ToLowerInvariant() switch
+ {
+ "skill" => new SolidColorBrush(Color.FromRgb(0x60, 0xA5, 0xFA)),
+ "tool" => new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)),
+ "theme" => new SolidColorBrush(Color.FromRgb(0xFB, 0xBF, 0x24)),
+ _ => new SolidColorBrush(Color.FromRgb(0x94, 0xA3, 0xB8)),
+ };
+}
diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml
index 7742b11..01e4064 100644
--- a/src/AxCopilot/Views/SettingsWindow.xaml
+++ b/src/AxCopilot/Views/SettingsWindow.xaml
@@ -5179,6 +5179,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs
index 4880836..9956c25 100644
--- a/src/AxCopilot/Views/SettingsWindow.xaml.cs
+++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs
@@ -77,6 +77,7 @@ public partial class SettingsWindow : Window
BuildToolRegistryPanel();
LoadAdvancedSettings();
RefreshStorageInfo();
+ InitPluginGallery(); // Phase 18-C1: 플러그인 갤러리 초기화
// 개발자 모드는 저장된 설정 유지 (끄면 하위 기능 모두 비활성)
UpdateDevModeContentVisibility();
// AI 기능 토글 초기화