From 82c12554d0dae27662096c69e5b568fc2392e152 Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:11:30 +0000 Subject: [PATCH] feat(CUB-62): [Control Center] Agent-to-Minion Mapping Service --- .../Models/AgentMinionMapping.cs | 72 +++++++ backend/ControlCenter/Program.cs | 5 + .../Services/AgentMinionMapperService.cs | 193 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 backend/ControlCenter/Models/AgentMinionMapping.cs create mode 100644 backend/ControlCenter/Services/AgentMinionMapperService.cs diff --git a/backend/ControlCenter/Models/AgentMinionMapping.cs b/backend/ControlCenter/Models/AgentMinionMapping.cs new file mode 100644 index 0000000..c9886dd --- /dev/null +++ b/backend/ControlCenter/Models/AgentMinionMapping.cs @@ -0,0 +1,72 @@ +namespace ControlCenter.Models; + +/// +/// Defines which side of the Control Center dashboard a minion occupies. +/// +public enum MinionSide +{ + /// Development side — Rex, Dex, Hex. + Dev, + + /// Business side — Larry, Mel, Buzz. + Business +} + +/// +/// Visual state of a minion sprite, derived from the agent's +/// . Maps Active/Idle/Thinking/Error +/// to frontend animation states. +/// +public enum MinionState +{ + /// Agent is actively processing — minion shows working animation. + Active, + + /// Agent is idle — minion shows idle/patrolling animation. + Idle, + + /// Agent is thinking (LLM call in flight) — minion shows thinking animation. + Thinking, + + /// Agent encountered an error — minion shows error/distress animation. + Error +} + +/// +/// Static mapping entry that associates an agent ID with a minion's +/// display side and position index within that side. +/// +/// Position indices are zero-based within each side. The dev side +/// has Rex at 0, Dex at 1, and Hex at 2. The business side has +/// Larry at 0, Mel at 1, and Buzz at 2. +/// +/// Agent identifier, e.g. "rex", "dex". +/// Which side of the dashboard the minion occupies. +/// Zero-based position index within the side. +/// Human-readable name, e.g. "Rex". +public record AgentMinionMapping( + string AgentId, + MinionSide Side, + int PositionIndex, + string DisplayName +); + +/// +/// Real-time minion state update pushed to SignalR clients +/// when an agent's status changes. Combines the static mapping +/// (who/where) with the dynamic state (what the minion is doing). +/// +/// Agent identifier, e.g. "rex". +/// Human-readable minion name, e.g. "Rex". +/// Which side of the dashboard — Dev or Business. +/// Position within the side (0-based). +/// Current minion animation state. +/// ISO 8601 timestamp of the state change. +public record MinionStateUpdate( + string AgentId, + string DisplayName, + MinionSide Side, + int PositionIndex, + MinionState State, + string Timestamp +); \ No newline at end of file diff --git a/backend/ControlCenter/Program.cs b/backend/ControlCenter/Program.cs index 757b20a..2b322ab 100644 --- a/backend/ControlCenter/Program.cs +++ b/backend/ControlCenter/Program.cs @@ -52,6 +52,11 @@ builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// ── Agent-Minion Mapper Service ──────────────────────────── +// Maps agents to minion sprites/positions and publishes state +// updates through SignalR. +builder.Services.AddSingleton(); + var app = builder.Build(); // ── Middleware ────────────────────────────────────────────── diff --git a/backend/ControlCenter/Services/AgentMinionMapperService.cs b/backend/ControlCenter/Services/AgentMinionMapperService.cs new file mode 100644 index 0000000..0dd82ef --- /dev/null +++ b/backend/ControlCenter/Services/AgentMinionMapperService.cs @@ -0,0 +1,193 @@ +using ControlCenter.Hubs; +using ControlCenter.Models; +using Microsoft.AspNetCore.SignalR; + +namespace ControlCenter.Services; + +/// +/// Service that maps Linear agents to minion sprites and positions +/// in the Control Center dashboard. +/// +/// Static mappings define where each minion appears: +/// +/// Dev side: Rex (0), Dex (1), Hex (2) +/// Business side: Larry (0), Mel (1), Buzz (2) +/// +/// +/// Dynamic state is derived from the agent's : +/// +/// Active +/// Idle +/// Thinking +/// Error +/// +/// +/// State updates are published through the +/// SignalR hub so that connected clients can animate minion sprites +/// in real time. +/// +public class AgentMinionMapperService +{ + private readonly ILogger _logger; + private readonly IHubContext _hubContext; + + /// + /// Static agent-to-minion mapping table. Defines which side and position + /// each agent's minion occupies on the dashboard. + /// + private static readonly Dictionary Mappings = new() + { + // ── Dev Side ────────────────────────────────── + ["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"), + ["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"), + ["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"), + + // ── Business Side ───────────────────────────── + ["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"), + ["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"), + ["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"), + }; + + /// + /// Maps string values to . + /// + private static readonly Dictionary StatusToMinionState = new() + { + ["active"] = MinionState.Active, + ["idle"] = MinionState.Idle, + ["thinking"] = MinionState.Thinking, + ["error"] = MinionState.Error, + }; + + public AgentMinionMapperService( + ILogger logger, + IHubContext hubContext) + { + _logger = logger; + _hubContext = hubContext; + } + + /// + /// Gets the minion mapping for a given agent ID. + /// Returns null if the agent is not mapped to a minion position. + /// + /// The agent identifier, e.g. "rex", "dex". + /// The mapping record, or null if unmapped. + public AgentMinionMapping? GetMapping(string agentId) + { + return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty); + } + + /// + /// Gets all minion mappings, ordered by side then position index. + /// + /// All mappings, sorted for consistent display order. + public IReadOnlyList GetAllMappings() + { + return Mappings.Values + .OrderBy(m => m.Side) + .ThenBy(m => m.PositionIndex) + .ToList(); + } + + /// + /// Converts an agent status string to a . + /// Falls back to for unrecognized statuses. + /// + /// Agent status string: "active", "idle", "thinking", or "error". + /// The corresponding minion state. + public MinionState StatusToState(string status) + { + return StatusToMinionState.GetValueOrDefault( + status?.ToLowerInvariant() ?? string.Empty, + MinionState.Idle); + } + + /// + /// Publishes a minion state update through SignalR when an agent's + /// status changes. Only publishes for agents that have a minion mapping. + /// + /// This is the primary integration point: the + /// calls this method + /// whenever it detects a status change from the OpenClaw Gateway. + /// + /// The agent whose status changed, e.g. "dex". + /// The new status string: "active", "idle", "thinking", or "error". + /// A task that completes when the SignalR message has been sent. + public async Task PublishMinionStateUpdateAsync(string agentId, string status) + { + var mapping = GetMapping(agentId); + if (mapping is null) + { + _logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId); + return; + } + + var minionState = StatusToState(status); + var update = new MinionStateUpdate( + AgentId: mapping.AgentId, + DisplayName: mapping.DisplayName, + Side: mapping.Side, + PositionIndex: mapping.PositionIndex, + State: minionState, + Timestamp: DateTime.UtcNow.ToString("o") + ); + + // Broadcast to the fleet group (all subscribers) + await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName) + .AgentStatusChanged(ToAgentStatusUpdate(agentId, status)); + + // Also push to the specific agent's group + var agentGroup = AgentStatusHub.AgentGroupName(agentId); + await _hubContext.Clients.Group(agentGroup) + .AgentStatusChanged(ToAgentStatusUpdate(agentId, status)); + + _logger.LogInformation( + "Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})", + agentId, minionState, mapping.Side, mapping.PositionIndex); + } + + /// + /// Gets the current minion state for all mapped agents, suitable + /// for building an initial fleet snapshot. + /// + /// All minion mappings with their current (idle) state. + public IReadOnlyList GetFullMinionState() + { + return Mappings.Values + .OrderBy(m => m.Side) + .ThenBy(m => m.PositionIndex) + .Select(m => new MinionStateUpdate( + AgentId: m.AgentId, + DisplayName: m.DisplayName, + Side: m.Side, + PositionIndex: m.PositionIndex, + State: MinionState.Idle, + Timestamp: DateTime.UtcNow.ToString("o"))) + .ToList(); + } + + /// + /// Converts a status string to an + /// for SignalR push. Uses the mapping table for display names and roles. + /// + private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status) + { + var mapping = GetMapping(agentId); + var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..]; + + return new AgentStatusUpdate( + AgentId: agentId, + DisplayName: displayName, + Role: mapping is not null + ? $"{mapping.Side} Agent" + : "Agent", + Status: status, + CurrentTask: null, + SessionKey: string.Empty, + Channel: string.Empty, + LastActivity: DateTime.UtcNow.ToString("o"), + ErrorMessage: status == "error" ? "Agent encountered an error" : null + ); + } +} \ No newline at end of file