From 4a62b6dee2d62ba1744316ef29c907bc338ca081 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 22:39:11 -0500 Subject: [PATCH 1/7] feat: surface subagent/skill events in chat + /agent command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show πŸ€–/▢️/βœ…/❌ system messages when CLI subagents activate (SubagentSelectedEvent, SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentDeselectedEvent) - Show ⚑ system message when SkillInvokedEvent fires - Update SdkEventMatrix: subagent + skill events β†’ ChatVisible - Handle CommandsChangedEvent: store CLI commands in AgentSessionInfo.CliSlashCommands - Add ActiveAgentName/ActiveAgentDisplayName to AgentSessionInfo - Show active CLI agent badge in ExpandedSessionView info panel - Add /agent slash command: list agents (local + SDK API) or select/deselect - Wire AgentApi: ListAgentsFromApiAsync, SelectAgentAsync, DeselectAgentAsync - Add /agent to index.html autocomplete + SlashCommandAutocompleteTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SlashCommandAutocompleteTests.cs | 2 +- .../Components/ExpandedSessionView.razor | 5 + .../Components/ExpandedSessionView.razor.css | 8 + PolyPilot/Components/Pages/Dashboard.razor | 54 +++++++ PolyPilot/Models/AgentSessionInfo.cs | 17 +++ PolyPilot/Services/CopilotService.Events.cs | 137 +++++++++++++++++- PolyPilot/Services/CopilotService.cs | 66 +++++++++ PolyPilot/wwwroot/index.html | 1 + 8 files changed, 283 insertions(+), 7 deletions(-) diff --git a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs index 69ce546d1..11ba12335 100644 --- a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs +++ b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs @@ -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", "/reflect", "/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..a7125f906 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -282,6 +282,11 @@ Β· @availableAgents.Count agents } + @if (!string.IsNullOrEmpty(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName)) + { + Β· + πŸ€– @(Session.ActiveAgentDisplayName ?? Session.ActiveAgentName) + } @if (availablePrompts != null) { Β· 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..d731cd25b 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1804,6 +1804,7 @@ "- `/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" + "- `/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" + @@ -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; @@ -2153,6 +2158,55 @@ } } + private async Task HandleAgentCommand(AgentSessionInfo session, string sessionName, string arg) + { + // /agent β€” list available agents + // /agent β€” select agent + // /agent deselect β€” deselect current agent + if (string.IsNullOrWhiteSpace(arg)) + { + // List agents from the API and from local discovery + var apiAgents = await CopilotService.ListAgentsFromApiAsync(sessionName); + var localAgents = session.AvailableAgents ?? []; + + var lines = new List { "**Available agents:**" }; + + if (apiAgents.Count > 0) + { + lines.Add("*CLI agents:*"); + foreach (var a in apiAgents) + lines.Add($"- `{a.Name}` β€” {a.Description}"); + } + + if (localAgents.Count > 0) + { + 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 + lines.Add("\nUse `/agent ` to select an agent, or `/agent deselect` to deselect."); + + session.History.Add(ChatMessage.SystemMessage(string.Join("\n", lines))); + } + else if (string.Equals(arg, "deselect", StringComparison.OrdinalIgnoreCase)) + { + await CopilotService.DeselectAgentAsync(sessionName); + session.History.Add(ChatMessage.SystemMessage("Agent deselected.")); + } + else + { + 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?")); + } + } + private async Task HandleReflectCommand(AgentSessionInfo session, string sessionName, string arg) { var subParts = arg.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index 45bf1d407..b9f930538 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -205,6 +205,23 @@ 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; } + + /// + /// Slash commands announced by the CLI session via CommandsChangedEvent. + /// These augment the static slash-command list in the autocomplete. + /// + public IReadOnlyList<(string Name, string Description)> CliSlashCommands { 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..8e3c76d14 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,130 @@ 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; + if (!string.IsNullOrEmpty(displayName)) + { + Invoke(() => + { + state.Info.History.Add(ChatMessage.SystemMessage($"βœ… Agent completed: **{displayName}**")); + // Clear active agent after completion + if (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}" : ""; + if (!string.IsNullOrEmpty(displayName)) + { + Invoke(() => + { + state.Info.History.Add(ChatMessage.ErrorMessage($"Agent failed: **{displayName}**{errDetail}")); + if (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; + } + + case CommandsChangedEvent commandsChanged: + { + var commands = commandsChanged.Data?.Commands; + if (commands != null && commands.Length > 0) + { + var list = commands + .Where(c => !string.IsNullOrEmpty(c?.Name)) + .Select(c => (c!.Name!, c.Description ?? "")) + .ToList(); + Invoke(() => + { + state.Info.CliSlashCommands = list; + NotifyStateChangedCoalesced(); + }); + } + break; + } + default: LogUnhandledSessionEvent(sessionName, evt); break; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 7dc691855..5fed376fe 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1906,6 +1906,72 @@ 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. + /// + public async Task DeselectAgentAsync(string sessionName) + { + if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) + return; + + try + { + await state.Session.Rpc.Agent.DeselectAsync(CancellationToken.None); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Agents] DeselectAsync failed for '{sessionName}': {ex.Message}"); + } + } + 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..3fb5734ea 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -759,6 +759,7 @@ 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 }, From f23a8f54077e13289da1eb5e84bca668781c8d50 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 22:47:35 -0500 Subject: [PATCH 2/7] fix: resolve MAUI build errors in agent visibility feature - Remove CommandsChangedEvent case handler (type not exported from SDK 0.2.0) SdkEventMatrix string entry remains for future use when SDK exposes the type - Replace session.AvailableAgents (nonexistent property) with CopilotService.DiscoverAvailableAgents(session.WorkingDirectory) in /agent handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 2 +- PolyPilot/Services/CopilotService.Events.cs | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index d731cd25b..08dcec23d 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -2167,7 +2167,7 @@ { // List agents from the API and from local discovery var apiAgents = await CopilotService.ListAgentsFromApiAsync(sessionName); - var localAgents = session.AvailableAgents ?? []; + var localAgents = CopilotService.DiscoverAvailableAgents(session.WorkingDirectory); var lines = new List { "**Available agents:**" }; diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 8e3c76d14..1ba1109ad 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -935,24 +935,6 @@ await notifService.SendNotificationAsync( break; } - case CommandsChangedEvent commandsChanged: - { - var commands = commandsChanged.Data?.Commands; - if (commands != null && commands.Length > 0) - { - var list = commands - .Where(c => !string.IsNullOrEmpty(c?.Name)) - .Select(c => (c!.Name!, c.Description ?? "")) - .ToList(); - Invoke(() => - { - state.Info.CliSlashCommands = list; - NotifyStateChangedCoalesced(); - }); - } - break; - } - default: LogUnhandledSessionEvent(sessionName, evt); break; From 4dd6d793e997f340603c14138badc16c28526dbd Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 06:34:54 -0500 Subject: [PATCH 3/7] chore: remove dead CliSlashCommands field from AgentSessionInfo CommandsChangedEvent handler was removed (SDK 0.2.0 doesn't export the type), so this field was never set. Remove to avoid dead code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Models/AgentSessionInfo.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index b9f930538..88d9bb90a 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -216,12 +216,6 @@ public string? LastUserPrompt /// public string? ActiveAgentDisplayName { get; set; } - /// - /// Slash commands announced by the CLI session via CommandsChangedEvent. - /// These augment the static slash-command list in the autocomplete. - /// - public IReadOnlyList<(string Name, string Description)> CliSlashCommands { get; set; } = []; - internal static readonly string[] QuestionPhrases = [ "let me know", "which would you prefer", "would you like", "should i", "do you want", From 5b8db196d71137937d8c7170ae2152ba086f166c Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 09:44:34 -0500 Subject: [PATCH 4/7] feat: add /fleet command + remove /reflect slash command - Add /fleet slash command that calls FleetApi.StartAsync() via the SDK, enabling parallel subagent execution. Shows a system message on success/failure. Added to index.html autocomplete and /help output. Added CopilotService.StartFleetAsync() wrapping state.Session.Rpc.Fleet.StartAsync(). - Remove /reflect slash command: remove case handler, delete HandleReflectCommand() method (~130 lines), remove from COMMANDS array and /help. The underlying reflection machinery (ReflectionCycle model, StartReflectionCycleAsync, OrchestratorReflect multi-agent mode, all reflection UI in ChatMessageItem/SessionCard) is UNTOUCHED -- reflection still works via multi-agent orchestration. - Update SlashCommandAutocompleteTests: replace /reflect with /fleet in both the minimum-commands and the hasArgs assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SlashCommandAutocompleteTests.cs | 4 +- PolyPilot/Components/Pages/Dashboard.razor | 137 ++---------------- PolyPilot/Services/CopilotService.cs | 21 +++ PolyPilot/wwwroot/index.html | 2 +- 4 files changed, 34 insertions(+), 130 deletions(-) diff --git a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs index 11ba12335..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", "/agent" }; + 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/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 08dcec23d..3b4a7d1bd 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1805,9 +1805,9 @@ "- `/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; @@ -1932,8 +1932,8 @@ HandlePluginCommand(session, arg); break; - case "reflect": - await HandleReflectCommand(session, sessionName, arg); + case "fleet": + await HandleFleetCommand(session, sessionName, arg); break; case "prompt": @@ -2207,136 +2207,19 @@ } } - private async Task HandleReflectCommand(AgentSessionInfo session, string sessionName, string arg) + private async Task HandleFleetCommand(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") - { - CopilotService.StopReflectionCycle(sessionName); - session.History.Add(ChatMessage.SystemMessage("πŸ›‘ Reflection cycle stopped.")); - return; - } - - 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; - } - - if (sub == "resume") - { - if (session.ReflectionCycle is { IsActive: true, IsPaused: true }) - { - 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); - } - } - else - { - session.History.Add(ChatMessage.ErrorMessage("No paused reflection cycle to resume.")); - } - return; - } - - if (sub == "status") - { - if (session.ReflectionCycle is { IsActive: true } rc) - { - 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}")); - } - else - { - session.History.Add(ChatMessage.SystemMessage("No active reflection cycle.")); - } - return; - } - - if (string.IsNullOrWhiteSpace(arg) || sub == "help") + if (string.IsNullOrWhiteSpace(arg)) { 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; - } - - // 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) - { - if (int.TryParse(maxMatch.Groups[1].Value, out var parsed) && parsed > 0) - maxIterations = parsed; - goal = arg.Remove(maxMatch.Index, maxMatch.Length).Trim(); - } - - if (string.IsNullOrWhiteSpace(goal)) - { - session.History.Add(ChatMessage.ErrorMessage("Please provide a goal for the reflection cycle.")); - return; - } - - await CopilotService.StartReflectionCycleAsync(sessionName, goal, maxIterations); - session.History.Add(ChatMessage.SystemMessage($"πŸ”„ Reflection cycle started β€” **{goal}** (max {maxIterations} iterations)")); - if (session.IsProcessing) - { - CopilotService.EnqueueMessage(sessionName, goal); - session.History.Add(ChatMessage.SystemMessage("⏳ Current turn is still running β€” queued reflection goal to run next.")); + "**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); + session.History.Add(ChatMessage.SystemMessage($"πŸš€ Starting fleet for: *{arg}*")); + var started = await CopilotService.StartFleetAsync(sessionName, arg); + if (!started) + 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/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 5fed376fe..4fd18ad03 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1972,6 +1972,27 @@ public async Task DeselectAgentAsync(string sessionName) } } + /// + /// 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; + + 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 3fb5734ea..28203829b 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -763,12 +763,12 @@ { 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 }, From 577b6843d1048208a9b624a7084f59d24f72d9aa Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 27 Mar 2026 09:52:26 -0500 Subject: [PATCH 5/7] fix: remove remaining /reflect UI references - Remove 'Reflect' button from ExpandedSessionView toolbar - Remove dead InsertReflectCommand() method - Remove '/reflect stop' suggestion from context-full warning message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ExpandedSessionView.razor | 10 ---------- PolyPilot/Services/CopilotService.Events.cs | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index a7125f906..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 Β· @@ -1048,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/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 1ba1109ad..255571d69 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -1513,7 +1513,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)) From 1e80fb2e66cb99064aeae81e6322911ad1f36791 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 08:01:20 -0500 Subject: [PATCH 6/7] fix: don't show fleet 'started' message before API confirms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only show πŸš€ success message after StartFleetAsync returns true, show error on false. Avoids premature success indicator on failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 3b4a7d1bd..653c3dd5c 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -2216,9 +2216,10 @@ return; } - session.History.Add(ChatMessage.SystemMessage($"πŸš€ Starting fleet for: *{arg}*")); var started = await CopilotService.StartFleetAsync(sessionName, arg); - if (!started) + 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.")); } From 09bc16e3d7da109236104132ff61e7b93ca7831e Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 28 Mar 2026 15:50:44 -0500 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20stuck=20badge,=20deselect=20error=20surface,=20flee?= =?UTF-8?q?t=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SubagentCompleted/Failed: always clear ActiveAgent state even when displayName is empty (prevents permanently stuck badge) - DeselectAgentAsync: return bool, surface failures in /agent deselect - StartFleetAsync: reject when IsProcessing to prevent concurrent turns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 7 +++- PolyPilot/Services/CopilotService.Events.cs | 43 ++++++++++----------- PolyPilot/Services/CopilotService.cs | 10 ++++- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 653c3dd5c..2a954bcef 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -2194,8 +2194,11 @@ } else if (string.Equals(arg, "deselect", StringComparison.OrdinalIgnoreCase)) { - await CopilotService.DeselectAgentAsync(sessionName); - session.History.Add(ChatMessage.SystemMessage("Agent deselected.")); + 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?")); } else { diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 255571d69..e9cf07c5e 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -881,20 +881,18 @@ await notifService.SendNotificationAsync( { var d = subagentCompleted.Data; var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; - if (!string.IsNullOrEmpty(displayName)) + Invoke(() => { - Invoke(() => - { + if (!string.IsNullOrEmpty(displayName)) state.Info.History.Add(ChatMessage.SystemMessage($"βœ… Agent completed: **{displayName}**")); - // Clear active agent after completion - if (string.Equals(state.Info.ActiveAgentName, d?.AgentName, StringComparison.OrdinalIgnoreCase)) - { - state.Info.ActiveAgentName = null; - state.Info.ActiveAgentDisplayName = null; - } - NotifyStateChangedCoalesced(); - }); - } + // 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; } @@ -903,19 +901,18 @@ await notifService.SendNotificationAsync( var d = subagentFailed.Data; var displayName = !string.IsNullOrEmpty(d?.AgentDisplayName) ? d.AgentDisplayName : d?.AgentName; var errDetail = !string.IsNullOrEmpty(d?.Error) ? $": {d.Error}" : ""; - if (!string.IsNullOrEmpty(displayName)) + Invoke(() => { - Invoke(() => - { + if (!string.IsNullOrEmpty(displayName)) state.Info.History.Add(ChatMessage.ErrorMessage($"Agent failed: **{displayName}**{errDetail}")); - if (string.Equals(state.Info.ActiveAgentName, d?.AgentName, StringComparison.OrdinalIgnoreCase)) - { - state.Info.ActiveAgentName = null; - state.Info.ActiveAgentDisplayName = null; - } - NotifyStateChangedCoalesced(); - }); - } + // 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; } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 4fd18ad03..d9aed2478 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1956,19 +1956,22 @@ public async Task SelectAgentAsync(string sessionName, string agentName) /// /// Deselects the active CLI agent for the given session. + /// Returns true on success, false on error. /// - public async Task DeselectAgentAsync(string sessionName) + public async Task DeselectAgentAsync(string sessionName) { if (!_sessions.TryGetValue(sessionName, out var state) || state.Session == null) - return; + 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; } } @@ -1981,6 +1984,9 @@ 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);