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 ); } }