diff --git a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs index 69ce546d1..a28ca692c 100644 --- a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs +++ b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs @@ -119,7 +119,7 @@ public void AutocompleteList_HasExpectedMinimumCommands() var commands = GetAutocompleteCommands(); var expected = new[] { "/help", "/clear", "/compact", "/new", "/sessions", "/rename", "/version", "/diff", "/status", "/mcp", - "/plugin", "/reflect", "/usage" }; + "/plugin", "/fleet", "/usage" }; foreach (var cmd in expected) { @@ -144,7 +144,7 @@ public void ParameterlessCommands_MarkedForAutoSend() } // Commands with args should have hasArgs: true - var withArgs = new[] { "/new", "/rename", "/diff", "/reflect", "/mcp", "/plugin", "/prompt", "/status" }; + var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/status", "/agent" }; foreach (var cmd in withArgs) { var pattern = $"cmd: '{cmd}',"; diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index f576599c4..a622656c9 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -264,10 +264,6 @@ - @if (Session.ReflectionCycle is null || !Session.ReflectionCycle.IsActive) - { - - } · Log · @@ -282,6 +278,11 @@ · @availableAgents.Count agents } + @if (!string.IsNullOrEmpty(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName)) + { + · + 🤖 @(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName) + } @if (availablePrompts != null) { · @@ -1043,12 +1044,6 @@ "); } - private async Task InsertReflectCommand() - { - var inputId = EscapeForJs("input-" + Session.Name.Replace(" ", "-")); - await JS.InvokeVoidAsync("eval", $"var el = document.getElementById('{inputId}'); if(el){{ el.value = '/reflect '; el.focus(); }}"); - } - private static string EscapeForJs(string s) => s.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", " ").Replace("\r", "") .Replace("\u2028", "\\u2028").Replace("\u2029", "\\u2029"); diff --git a/PolyPilot/Components/ExpandedSessionView.razor.css b/PolyPilot/Components/ExpandedSessionView.razor.css index 61c7e6b43..3f6812a21 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor.css +++ b/PolyPilot/Components/ExpandedSessionView.razor.css @@ -682,6 +682,14 @@ cursor: pointer; } +.active-agent-badge { + font-size: var(--type-footnote); + color: var(--text-secondary, #888); + background: var(--hover-bg, rgba(124,92,252,0.08)); + border-radius: 4px; + padding: 0 0.3rem; +} + /* === Mobile responsive === */ @media (max-width: 640px) { .status-extra { display: none; } diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 9d9b815aa..2a954bcef 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1804,9 +1804,10 @@ "- `/status` — Show git status\n" + "- `/prompt` — List saved prompts (`/prompt use|save|edit|show|delete`)\n" + "- `/usage` — Show token usage and quota for this session\n" + + "- `/agent [name]` — List or select a CLI agent\n" + + "- `/fleet ` — Start fleet mode (parallel subagent execution)\n" + "- `/mcp` — List MCP servers (`/mcp enable|disable `, `/mcp reload` to restart)\n" + "- `/plugin` — List installed plugins (enable/disable with `/plugin enable|disable `)\n" + - "- `/reflect ` — Start a reflection cycle (`/reflect help` for details)\n" + "- `!` — Run a shell command")); break; @@ -1815,6 +1816,10 @@ session.History.Add(ChatMessage.SystemMessage("Chat history cleared.")); break; + case "agent": + await HandleAgentCommand(session, sessionName, arg); + break; + case "version": var appVersion = AppInfo.Current.VersionString; var buildVersion = AppInfo.Current.BuildString; @@ -1927,8 +1932,8 @@ HandlePluginCommand(session, arg); break; - case "reflect": - await HandleReflectCommand(session, sessionName, arg); + case "fleet": + await HandleFleetCommand(session, sessionName, arg); break; case "prompt": @@ -2153,136 +2158,72 @@ } } - private async Task HandleReflectCommand(AgentSessionInfo session, string sessionName, string arg) + private async Task HandleAgentCommand(AgentSessionInfo session, string sessionName, string arg) { - var subParts = arg.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var sub = subParts.Length > 0 ? subParts[0].ToLowerInvariant() : ""; - - if (sub == "stop") + // /agent — list available agents + // /agent — select agent + // /agent deselect — deselect current agent + if (string.IsNullOrWhiteSpace(arg)) { - CopilotService.StopReflectionCycle(sessionName); - session.History.Add(ChatMessage.SystemMessage("🛑 Reflection cycle stopped.")); - return; - } + // List agents from the API and from local discovery + var apiAgents = await CopilotService.ListAgentsFromApiAsync(sessionName); + var localAgents = CopilotService.DiscoverAvailableAgents(session.WorkingDirectory); - if (sub == "pause") - { - if (session.ReflectionCycle is { IsActive: true, IsPaused: false }) - { - session.ReflectionCycle.IsPaused = true; - session.History.Add(ChatMessage.SystemMessage("⏸️ Reflection paused. Use `/reflect resume` to continue.")); - } - else - { - session.History.Add(ChatMessage.ErrorMessage("No active reflection cycle to pause.")); - } - return; - } + var lines = new List { "**Available agents:**" }; - if (sub == "resume") - { - if (session.ReflectionCycle is { IsActive: true, IsPaused: true }) + if (apiAgents.Count > 0) { - session.ReflectionCycle.IsPaused = false; - session.ReflectionCycle.ResetStallDetection(); - session.History.Add(ChatMessage.SystemMessage("▶️ Reflection resumed.")); - // Re-queue a follow-up to continue the cycle - var followUp = session.ReflectionCycle.BuildFollowUpPrompt(""); - if (session.IsProcessing) - { - session.MessageQueue.Add(followUp); - } - else - { - // Dispatch immediately if session is idle - _ = CopilotService.SendPromptAsync(sessionName, followUp, skipHistoryMessage: true); - } + lines.Add("*CLI agents:*"); + foreach (var a in apiAgents) + lines.Add($"- `{a.Name}` — {a.Description}"); } - else - { - session.History.Add(ChatMessage.ErrorMessage("No paused reflection cycle to resume.")); - } - return; - } - if (sub == "status") - { - if (session.ReflectionCycle is { IsActive: true } rc) + if (localAgents.Count > 0) { - var status = rc.IsPaused ? "⏸️ Paused" : "🔄 Running"; - var evalInfo = !string.IsNullOrEmpty(rc.EvaluatorSessionName) ? "independent evaluator" : "self-evaluation"; - var feedback = !string.IsNullOrEmpty(rc.EvaluatorFeedback) ? $"\n**Last feedback:** {rc.EvaluatorFeedback}" : ""; - session.History.Add(ChatMessage.SystemMessage( - $"{status} — **{rc.Goal}**\n" + - $"Iteration {rc.CurrentIteration}/{rc.MaxIterations} · {evalInfo}{feedback}")); + lines.Add("*Local agents (from repo):*"); + foreach (var a in localAgents) + lines.Add($"- `{a.Name}` — {a.Description}"); } + + if (apiAgents.Count == 0 && localAgents.Count == 0) + lines.Add("No agents found. Try adding agent markdown files in `.github/agents/` or `.claude/agents/`."); else - { - session.History.Add(ChatMessage.SystemMessage("No active reflection cycle.")); - } - return; - } + lines.Add("\nUse `/agent ` to select an agent, or `/agent deselect` to deselect."); - if (string.IsNullOrWhiteSpace(arg) || sub == "help") - { - session.History.Add(ChatMessage.SystemMessage( - "🔄 **Reflection Cycles** — Iterative goal-driven refinement\n\n" + - "**Usage:**\n" + - "```\n" + - "/reflect Start a cycle (default 5 iterations)\n" + - "/reflect --max N Set max iterations (default 5)\n" + - "/reflect stop Cancel active cycle\n" + - "/reflect pause Pause without cancelling\n" + - "/reflect resume Resume paused cycle\n" + - "/reflect status Show current cycle progress\n" + - "/reflect help Show this help\n" + - "```\n\n" + - "**How it works:**\n" + - "1. You set a goal → the worker starts iterating\n" + - "2. After each response, an **independent evaluator** judges the result\n" + - "3. If the evaluator says FAIL, its feedback is sent back to the worker\n" + - "4. Cycle ends when: ✅ evaluator says PASS | ⚠️ stalled | ⏱️ max iterations\n\n" + - "**Examples:**\n" + - "```\n" + - "/reflect write a haiku about rain --max 4\n" + - "/reflect fix the login bug and add tests --max 8\n" + - "/reflect refactor this function for readability\n" + - "```\n\n" + - "**Tips:**\n" + - "- Send messages during a cycle to steer the worker\n" + - "- Click the 🔄 pill in the header to stop\n" + - "- The evaluator is strict early on, lenient on the final iteration")); - return; + session.History.Add(ChatMessage.SystemMessage(string.Join("\n", lines))); } - - // Parse --max N from the goal text (accept em-dash — which macOS auto-substitutes for --) - int maxIterations = 5; - var goal = arg; - var maxMatch = System.Text.RegularExpressions.Regex.Match(arg, @"(?:--|—|\u2014)max\s+(\d+)"); - if (maxMatch.Success) + else if (string.Equals(arg, "deselect", StringComparison.OrdinalIgnoreCase)) { - if (int.TryParse(maxMatch.Groups[1].Value, out var parsed) && parsed > 0) - maxIterations = parsed; - goal = arg.Remove(maxMatch.Index, maxMatch.Length).Trim(); + var success = await CopilotService.DeselectAgentAsync(sessionName); + if (success) + session.History.Add(ChatMessage.SystemMessage("Agent deselected.")); + else + session.History.Add(ChatMessage.ErrorMessage("Failed to deselect agent. Is the session connected?")); } - - if (string.IsNullOrWhiteSpace(goal)) + else { - session.History.Add(ChatMessage.ErrorMessage("Please provide a goal for the reflection cycle.")); - return; + var success = await CopilotService.SelectAgentAsync(sessionName, arg); + if (success) + session.History.Add(ChatMessage.SystemMessage($"Agent **{arg}** selected.")); + else + session.History.Add(ChatMessage.ErrorMessage($"Failed to select agent '{arg}'. Is the agent name correct?")); } + } - await CopilotService.StartReflectionCycleAsync(sessionName, goal, maxIterations); - session.History.Add(ChatMessage.SystemMessage($"🔄 Reflection cycle started — **{goal}** (max {maxIterations} iterations)")); - if (session.IsProcessing) + private async Task HandleFleetCommand(AgentSessionInfo session, string sessionName, string arg) + { + if (string.IsNullOrWhiteSpace(arg)) { - CopilotService.EnqueueMessage(sessionName, goal); - session.History.Add(ChatMessage.SystemMessage("⏳ Current turn is still running — queued reflection goal to run next.")); + session.History.Add(ChatMessage.SystemMessage( + "**Usage:** `/fleet `\n\nStarts fleet mode, which enables parallel subagent execution for the given prompt.")); return; } - // Send the goal as the initial prompt to kick off the first iteration - _ = CopilotService.SendPromptAsync(sessionName, goal); + var started = await CopilotService.StartFleetAsync(sessionName, arg); + if (started) + session.History.Add(ChatMessage.SystemMessage($"🚀 Fleet started for: *{arg}*")); + else + session.History.Add(ChatMessage.ErrorMessage("Failed to start fleet mode. Ensure the session is connected and idle.")); } private async Task HandlePromptCommand(string sessionName, AgentSessionInfo session, string arg) diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index 45bf1d407..88d9bb90a 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -205,6 +205,17 @@ public string? LastUserPrompt /// public bool IsOrchestratorWorker { get; set; } + /// + /// The name of the CLI subagent currently active in this session (e.g. "code-review"), + /// or null if no subagent is active. Updated by SubagentSelectedEvent / SubagentDeselectedEvent. + /// + public string? ActiveAgentName { get; set; } + + /// + /// Display name of the active subagent (e.g. "Code Review"), or null if none. + /// + public string? ActiveAgentDisplayName { get; set; } + internal static readonly string[] QuestionPhrases = [ "let me know", "which would you prefer", "would you like", "should i", "do you want", diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 9e8246f45..e9cf07c5e 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -54,11 +54,13 @@ private enum EventVisibility ["SessionCompactionCompleteEvent"] = EventVisibility.TimelineOnly, ["PendingMessagesModifiedEvent"] = EventVisibility.TimelineOnly, ["ToolUserRequestedEvent"] = EventVisibility.TimelineOnly, - ["SkillInvokedEvent"] = EventVisibility.TimelineOnly, - ["SubagentSelectedEvent"] = EventVisibility.TimelineOnly, - ["SubagentStartedEvent"] = EventVisibility.TimelineOnly, - ["SubagentCompletedEvent"] = EventVisibility.TimelineOnly, - ["SubagentFailedEvent"] = EventVisibility.TimelineOnly, + ["SkillInvokedEvent"] = EventVisibility.ChatVisible, + ["SubagentSelectedEvent"] = EventVisibility.ChatVisible, + ["SubagentDeselectedEvent"] = EventVisibility.ChatVisible, + ["SubagentStartedEvent"] = EventVisibility.ChatVisible, + ["SubagentCompletedEvent"] = EventVisibility.ChatVisible, + ["SubagentFailedEvent"] = EventVisibility.ChatVisible, + ["CommandsChangedEvent"] = EventVisibility.TimelineOnly, // Currently noisy internal events ["SessionLifecycleEvent"] = EventVisibility.Ignore, @@ -827,7 +829,109 @@ await notifService.SendNotificationAsync( Invoke(() => OnStateChanged?.Invoke()); } break; - + + // ────────────────────────────────────────────────────────────────────── + // Subagent lifecycle: the CLI can automatically select specialized agents + // (e.g. code-review, security-review) when processing a prompt. + // Show these in chat so the user knows which agent is active. + // ────────────────────────────────────────────────────────────────────── + case SubagentSelectedEvent subagentSelected: + { + var d = subagentSelected.Data; + var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; + if (!string.IsNullOrEmpty(displayName)) + { + Invoke(() => + { + state.Info.ActiveAgentName = d!.AgentName; + state.Info.ActiveAgentDisplayName = displayName; + state.Info.History.Add(ChatMessage.SystemMessage($"🤖 Agent: **{displayName}**")); + NotifyStateChangedCoalesced(); + }); + } + break; + } + + case SubagentDeselectedEvent: + Invoke(() => + { + state.Info.ActiveAgentName = null; + state.Info.ActiveAgentDisplayName = null; + NotifyStateChangedCoalesced(); + }); + break; + + case SubagentStartedEvent subagentStarted: + { + var d = subagentStarted.Data; + var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; + if (!string.IsNullOrEmpty(displayName)) + { + var desc = !string.IsNullOrEmpty(d?.AgentDescription) ? $" — {d.AgentDescription}" : ""; + Invoke(() => + { + state.Info.History.Add(ChatMessage.SystemMessage($"▶️ Starting agent: **{displayName}**{desc}")); + NotifyStateChangedCoalesced(); + }); + } + break; + } + + case SubagentCompletedEvent subagentCompleted: + { + var d = subagentCompleted.Data; + var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; + Invoke(() => + { + if (!string.IsNullOrEmpty(displayName)) + state.Info.History.Add(ChatMessage.SystemMessage($"✅ Agent completed: **{displayName}**")); + // Always clear active agent state — even if displayName is empty + if (d?.AgentName == null || string.Equals(state.Info.ActiveAgentName, d.AgentName, StringComparison.OrdinalIgnoreCase)) + { + state.Info.ActiveAgentName = null; + state.Info.ActiveAgentDisplayName = null; + } + NotifyStateChangedCoalesced(); + }); + break; + } + + case SubagentFailedEvent subagentFailed: + { + var d = subagentFailed.Data; + var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; + var errDetail = !string.IsNullOrEmpty(d?.Error) ? $": {d.Error}" : ""; + Invoke(() => + { + if (!string.IsNullOrEmpty(displayName)) + state.Info.History.Add(ChatMessage.ErrorMessage($"Agent failed: **{displayName}**{errDetail}")); + // Always clear active agent state — even if displayName is empty + if (d?.AgentName == null || string.Equals(state.Info.ActiveAgentName, d.AgentName, StringComparison.OrdinalIgnoreCase)) + { + state.Info.ActiveAgentName = null; + state.Info.ActiveAgentDisplayName = null; + } + NotifyStateChangedCoalesced(); + }); + break; + } + + case SkillInvokedEvent skillInvoked: + { + var skillName = skillInvoked.Data?.Name; + var pluginName = skillInvoked.Data?.PluginName; + var label = !string.IsNullOrEmpty(pluginName) ? $"{skillName} ({pluginName})" : skillName; + if (!string.IsNullOrEmpty(label)) + { + Invoke(() => + { + state.Info.History.Add(ChatMessage.SystemMessage($"⚡ Skill: **{label}**")); + NotifyStateChangedCoalesced(); + }); + } + break; + } + default: LogUnhandledSessionEvent(sessionName, evt); break; @@ -1406,7 +1510,7 @@ private void HandleReflectionAdvanceResult(SessionState state, string response, var ctxPct = (double)state.Info.ContextCurrentTokens.Value / state.Info.ContextTokenLimit.Value; if (ctxPct > 0.9) { - var ctxWarning = ChatMessage.SystemMessage($"🔴 Context {ctxPct:P0} full — reflection may lose earlier history. Consider `/reflect stop`."); + var ctxWarning = ChatMessage.SystemMessage($"🔴 Context {ctxPct:P0} full — reflection may lose earlier history."); state.Info.History.Add(ctxWarning); state.Info.MessageCount = state.Info.History.Count; if (!string.IsNullOrEmpty(state.Info.SessionId)) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 7dc691855..d9aed2478 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1906,6 +1906,99 @@ public List DiscoverAvailableAgents(string? workingDirectory) return agents; } + /// + /// Lists agents available in the current session via the SDK AgentApi. + /// Returns an empty list if the session doesn't exist, is not connected, or the API fails. + /// + public async Task> ListAgentsFromApiAsync(string sessionName) + { + if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) + return []; + + try + { + var result = await state.Session.Rpc.Agent.ListAsync(CancellationToken.None); + return result?.Agents? + .Where(a => !string.IsNullOrEmpty(a?.Name)) + .Select(a => new AgentInfo( + a!.Name!, + a.Description ?? a.DisplayName ?? "", + "cli")) + .ToList() ?? []; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Agents] ListAsync failed for '{sessionName}': {ex.Message}"); + return []; + } + } + + /// + /// Selects a CLI agent for the given session via the SDK AgentApi. + /// Returns true on success, false on error. + /// + public async Task SelectAgentAsync(string sessionName, string agentName) + { + if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) + return false; + + try + { + await state.Session.Rpc.Agent.SelectAsync(agentName, CancellationToken.None); + return true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Agents] SelectAsync('{agentName}') failed for '{sessionName}': {ex.Message}"); + return false; + } + } + + /// + /// Deselects the active CLI agent for the given session. + /// Returns true on success, false on error. + /// + public async Task DeselectAgentAsync(string sessionName) + { + if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) + return false; + + try + { + await state.Session.Rpc.Agent.DeselectAsync(CancellationToken.None); + return true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Agents] DeselectAsync failed for '{sessionName}': {ex.Message}"); + return false; + } + } + + /// + /// Starts fleet mode (parallel subagent execution) for the given session with the provided prompt. + /// Returns true if the fleet was started successfully, false otherwise. + /// + public async Task StartFleetAsync(string sessionName, string prompt) + { + if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) + return false; + + if (state.Info.IsProcessing) + return false; + + try + { + var result = await state.Session.Rpc.Fleet.StartAsync(prompt, CancellationToken.None); + return result?.Started ?? false; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Fleet] StartAsync failed for '{sessionName}': {ex.Message}"); + return false; + } + } + private static void ScanAgentDirectory(string agentsDir, string source, List agents, HashSet seen) { foreach (var file in Directory.GetFiles(agentsDir, "*.md")) diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index b3a2ef739..28203829b 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -759,15 +759,16 @@ window.__slashCmdSetup = true; var COMMANDS = [ + { cmd: '/agent', usage: '[name]', desc: 'List or select a CLI agent', hasArgs: true }, { cmd: '/clear', desc: 'Clear chat history', hasArgs: false }, { cmd: '/compact', desc: 'Summarize conversation', hasArgs: false }, { cmd: '/diff', usage: '[args]', desc: 'Show git diff', hasArgs: true }, + { cmd: '/fleet', usage: '', desc: 'Start fleet mode (parallel subagent execution)', hasArgs: true }, { cmd: '/help', desc: 'Show available commands', hasArgs: false }, { cmd: '/mcp', usage: '[show|add|edit|delete|disable|enable] [server-name] | reload', desc: 'Manage MCP servers', hasArgs: true }, { cmd: '/new', usage: '[name]', desc: 'Create a new session', hasArgs: true }, { cmd: '/plugin', usage: '[enable|disable] [plugin-name]', desc: 'Manage installed plugins', hasArgs: true }, { cmd: '/prompt', usage: '[use|save|delete] [name]', desc: 'List saved prompts', hasArgs: true }, - { cmd: '/reflect', usage: '', desc: 'Start a reflection cycle', hasArgs: true }, { cmd: '/rename', usage: '', desc: 'Rename current session', hasArgs: true }, { cmd: '/sessions', desc: 'List all sessions', hasArgs: false }, { cmd: '/status', usage: '[path] [--short]', desc: 'Show git status', hasArgs: true },